From 1e83456e3cde8d715cf1727adefc6e33ec408549 Mon Sep 17 00:00:00 2001 From: Jonathan Osorio Alcala Date: Wed, 9 Jul 2025 21:17:25 -0600 Subject: [PATCH 1/6] [IMP] session_db: Add a way to parse binary data from session data --- session_db/pg_session_store.py | 61 +++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/session_db/pg_session_store.py b/session_db/pg_session_store.py index ad47eb4fec0..8ea16f9b067 100644 --- a/session_db/pg_session_store.py +++ b/session_db/pg_session_store.py @@ -5,7 +5,9 @@ import json import logging import os +import re +import base64 import psycopg2 import odoo @@ -66,6 +68,7 @@ def __init__(self, uri, session_class=None): self._cr = None self._open_connection() self._setup_db() + self.prefix_binary = "base64::" def __del__(self): self._close_connection() @@ -108,7 +111,8 @@ def _setup_db(self): @with_lock @with_cursor def save(self, session): - payload = json.dumps(dict(session)) + json_session = self.session_to_str(dict(session)) + payload = json.dumps(json_session) self._cr.execute( """ INSERT INTO http_sessions(sid, write_date, payload) @@ -131,6 +135,7 @@ def get(self, sid): self._cr.execute("SELECT payload FROM http_sessions WHERE sid=%s", (sid,)) try: data = json.loads(self._cr.fetchone()[0]) + data = self.str_to_session(data) except Exception: return self.new() @@ -149,6 +154,60 @@ def vacuum(self, max_lifetime=http.SESSION_LIFETIME): (f"{max_lifetime} seconds",), ) + def _traverse_and_convert(self, data_node, conversion_func): + """Helper method that preserves keys while converting values. + """ + if isinstance(data_node, dict): + return {self._traverse_and_convert(key, conversion_func): self._traverse_and_convert( + value, conversion_func) for key, value in data_node.items()} + if isinstance(data_node, list): + return [self._traverse_and_convert(item, conversion_func) for item in data_node] + else: + return conversion_func(data_node) + + def session_to_str(self, data): + """Converts binary values to prefixed strings. + """ + def convert(value): + if isinstance(value, bytes): + base64_string = base64.b64encode(value).decode('utf-8') + return self.prefix_binary + base64_string + return value + + return self._traverse_and_convert(data, convert) + + def str_to_session(self, data): + """Converts binary str to binary value again. + Converts int/float str values convert to their respective types. + """ + def convert(value): + if not isinstance(value, str): + return value # Only process strings + # 1. Check for binary + if value.startswith(self.prefix_binary): + base64_string = value[len(self.prefix_binary):] + try: + return base64.b64decode(base64_string) + except (ValueError, TypeError): + return value + # 2. Check for float (positive or negative) + # This regex requires a decimal point. + if re.match(r'^-?\d+\.\d+$', value): + try: + return float(value) + except (ValueError, TypeError): + return value + # 3. Check for integer (positive or negative) + # This regex matches only digits (with optional sign). + if re.match(r'^-?\d+$', value): + try: + return int(value) + except (ValueError, TypeError): + return value + return value + + return self._traverse_and_convert(data, convert) + _original_session_store = http.root.__class__.session_store From ba19b2e07d82d43ed3975e5e3275892bf7dc665a Mon Sep 17 00:00:00 2001 From: Jonathan Osorio Alcala Date: Wed, 12 Nov 2025 17:23:08 -0600 Subject: [PATCH 2/6] [FIX] session_db: fix when convert debug value --- session_db/pg_session_store.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/session_db/pg_session_store.py b/session_db/pg_session_store.py index 8ea16f9b067..be0c4777210 100644 --- a/session_db/pg_session_store.py +++ b/session_db/pg_session_store.py @@ -158,8 +158,18 @@ def _traverse_and_convert(self, data_node, conversion_func): """Helper method that preserves keys while converting values. """ if isinstance(data_node, dict): - return {self._traverse_and_convert(key, conversion_func): self._traverse_and_convert( - value, conversion_func) for key, value in data_node.items()} + res = {} + for key, value in data_node.items(): + # This is necessary because Odoo's core (ir_qweb) needs the 'debug' value as a string. + # The value for this key can be: "1", "assets", "True", "False", etc. + # Ref: https://github.com/Vauxoo/odoo/blob/d4d64d613800b8dc44c3262e13a2a81dbf3c742c/odoo/addons/base/models/ir_qweb.py#L912 + # A test on an Odoo instance without the 'session_db' module confirmed + # that 'request.session.debug' value is always a string (str) type. + if key != "debug": + key = self._traverse_and_convert(key, conversion_func) + value = self._traverse_and_convert(value, conversion_func) + res.update({key: value}) + return res if isinstance(data_node, list): return [self._traverse_and_convert(item, conversion_func) for item in data_node] else: From 6173b0bbce91ab36d204d987665cf44c2bee7987 Mon Sep 17 00:00:00 2001 From: Jonathan Osorio Alcala Date: Wed, 18 Feb 2026 15:02:54 -0600 Subject: [PATCH 3/6] fix de lints --- session_db/pg_session_store.py | 55 ++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/session_db/pg_session_store.py b/session_db/pg_session_store.py index be0c4777210..6a89e827830 100644 --- a/session_db/pg_session_store.py +++ b/session_db/pg_session_store.py @@ -2,12 +2,12 @@ # @author Nicolas Seinlet # Copyright (c) ACSONE SA 2022 # @author Stéphane Bidoul +import base64 import json import logging import os import re -import base64 import psycopg2 import odoo @@ -155,14 +155,15 @@ def vacuum(self, max_lifetime=http.SESSION_LIFETIME): ) def _traverse_and_convert(self, data_node, conversion_func): - """Helper method that preserves keys while converting values. - """ + """Helper method that preserves keys while converting values.""" if isinstance(data_node, dict): res = {} for key, value in data_node.items(): - # This is necessary because Odoo's core (ir_qweb) needs the 'debug' value as a string. + # This is necessary because Odoo's core (ir_qweb) needs the 'debug' value as + # a string. # The value for this key can be: "1", "assets", "True", "False", etc. - # Ref: https://github.com/Vauxoo/odoo/blob/d4d64d613800b8dc44c3262e13a2a81dbf3c742c/odoo/addons/base/models/ir_qweb.py#L912 + # Ref: https://github.com/Vauxoo/odoo/blob/d4d64d613800b8dc44c3262e13/ + # odoo/addons/base/models/ir_qweb.py#L912 # A test on an Odoo instance without the 'session_db' module confirmed # that 'request.session.debug' value is always a string (str) type. if key != "debug": @@ -171,16 +172,17 @@ def _traverse_and_convert(self, data_node, conversion_func): res.update({key: value}) return res if isinstance(data_node, list): - return [self._traverse_and_convert(item, conversion_func) for item in data_node] - else: - return conversion_func(data_node) + return [ + self._traverse_and_convert(item, conversion_func) for item in data_node + ] + return conversion_func(data_node) def session_to_str(self, data): - """Converts binary values to prefixed strings. - """ + """Converts binary values to prefixed strings.""" + def convert(value): if isinstance(value, bytes): - base64_string = base64.b64encode(value).decode('utf-8') + base64_string = base64.b64encode(value).decode("utf-8") return self.prefix_binary + base64_string return value @@ -190,30 +192,31 @@ def str_to_session(self, data): """Converts binary str to binary value again. Converts int/float str values convert to their respective types. """ + def convert(value): if not isinstance(value, str): return value # Only process strings # 1. Check for binary if value.startswith(self.prefix_binary): - base64_string = value[len(self.prefix_binary):] + base64_string = value[len(self.prefix_binary) :] try: return base64.b64decode(base64_string) except (ValueError, TypeError): return value - # 2. Check for float (positive or negative) - # This regex requires a decimal point. - if re.match(r'^-?\d+\.\d+$', value): - try: - return float(value) - except (ValueError, TypeError): - return value - # 3. Check for integer (positive or negative) - # This regex matches only digits (with optional sign). - if re.match(r'^-?\d+$', value): - try: - return int(value) - except (ValueError, TypeError): - return value + numeric_parsers = [ + # 2. Check for float (positive or negative) + # This regex requires a decimal point. + (r"^-?\d+\.\d+$", float), + # 3. Check for integer (positive or negative) + # This regex matches only digits (with optional sign). + (r"^-?\d+$", int), + ] + for pattern, parser in numeric_parsers: + if re.match(pattern, value): + try: + return parser(value) + except (ValueError, TypeError): + return value return value return self._traverse_and_convert(data, convert) From 08e097f50b537d5303f53f5a6315ffe7cc579122 Mon Sep 17 00:00:00 2001 From: Jonathan Osorio Alcala Date: Wed, 18 Feb 2026 15:15:14 -0600 Subject: [PATCH 4/6] [REF] session_db: remove check init/float data, only verify binary, also remove debug check --- session_db/pg_session_store.py | 44 +++++++--------------------------- 1 file changed, 9 insertions(+), 35 deletions(-) diff --git a/session_db/pg_session_store.py b/session_db/pg_session_store.py index 6a89e827830..13e2c603510 100644 --- a/session_db/pg_session_store.py +++ b/session_db/pg_session_store.py @@ -159,17 +159,9 @@ def _traverse_and_convert(self, data_node, conversion_func): if isinstance(data_node, dict): res = {} for key, value in data_node.items(): - # This is necessary because Odoo's core (ir_qweb) needs the 'debug' value as - # a string. - # The value for this key can be: "1", "assets", "True", "False", etc. - # Ref: https://github.com/Vauxoo/odoo/blob/d4d64d613800b8dc44c3262e13/ - # odoo/addons/base/models/ir_qweb.py#L912 - # A test on an Odoo instance without the 'session_db' module confirmed - # that 'request.session.debug' value is always a string (str) type. - if key != "debug": - key = self._traverse_and_convert(key, conversion_func) - value = self._traverse_and_convert(value, conversion_func) - res.update({key: value}) + res.update({ + self._traverse_and_convert(key, conversion_func): self._traverse_and_convert(value, conversion_func) + }) return res if isinstance(data_node, list): return [ @@ -190,34 +182,16 @@ def convert(value): def str_to_session(self, data): """Converts binary str to binary value again. - Converts int/float str values convert to their respective types. """ def convert(value): - if not isinstance(value, str): + if not isinstance(value, str) or not value.startswith(self.prefix_binary): return value # Only process strings - # 1. Check for binary - if value.startswith(self.prefix_binary): - base64_string = value[len(self.prefix_binary) :] - try: - return base64.b64decode(base64_string) - except (ValueError, TypeError): - return value - numeric_parsers = [ - # 2. Check for float (positive or negative) - # This regex requires a decimal point. - (r"^-?\d+\.\d+$", float), - # 3. Check for integer (positive or negative) - # This regex matches only digits (with optional sign). - (r"^-?\d+$", int), - ] - for pattern, parser in numeric_parsers: - if re.match(pattern, value): - try: - return parser(value) - except (ValueError, TypeError): - return value - return value + base64_string = value[len(self.prefix_binary) :] + try: + return base64.b64decode(base64_string) + except (ValueError, TypeError): + return value return self._traverse_and_convert(data, convert) From e5f11c6a7ae89ff7be78218a706c88f1c57108fa Mon Sep 17 00:00:00 2001 From: Jonathan Osorio Alcala Date: Wed, 18 Feb 2026 22:29:00 -0600 Subject: [PATCH 5/6] Fix lint --- session_db/pg_session_store.py | 39 +++++++++++++++------------------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/session_db/pg_session_store.py b/session_db/pg_session_store.py index 13e2c603510..6e6430f2f2c 100644 --- a/session_db/pg_session_store.py +++ b/session_db/pg_session_store.py @@ -6,7 +6,6 @@ import json import logging import os -import re import psycopg2 @@ -155,43 +154,39 @@ def vacuum(self, max_lifetime=http.SESSION_LIFETIME): ) def _traverse_and_convert(self, data_node, conversion_func): - """Helper method that preserves keys while converting values.""" + """ + Recursively applies a conversion function to all elements in dicts and lists. + """ if isinstance(data_node, dict): - res = {} - for key, value in data_node.items(): - res.update({ - self._traverse_and_convert(key, conversion_func): self._traverse_and_convert(value, conversion_func) - }) - return res + return { + self._traverse_and_convert( + key, conversion_func + ): self._traverse_and_convert(value, conversion_func) + for key, value in data_node.items() + } if isinstance(data_node, list): return [ self._traverse_and_convert(item, conversion_func) for item in data_node ] + return conversion_func(data_node) def session_to_str(self, data): - """Converts binary values to prefixed strings.""" - def convert(value): if isinstance(value, bytes): - base64_string = base64.b64encode(value).decode("utf-8") - return self.prefix_binary + base64_string + return self.prefix_binary + base64.b64encode(value).decode("utf-8") return value return self._traverse_and_convert(data, convert) def str_to_session(self, data): - """Converts binary str to binary value again. - """ - def convert(value): - if not isinstance(value, str) or not value.startswith(self.prefix_binary): - return value # Only process strings - base64_string = value[len(self.prefix_binary) :] - try: - return base64.b64decode(base64_string) - except (ValueError, TypeError): - return value + if isinstance(value, str) and value.startswith(self.prefix_binary): + try: + return base64.b64decode(value[len(self.prefix_binary) :]) + except (ValueError, TypeError): + return value + return value return self._traverse_and_convert(data, convert) From c1f1fcb669cdf38f46cd9cacb675e0273efa003a Mon Sep 17 00:00:00 2001 From: Jonathan Osorio Alcala Date: Thu, 19 Feb 2026 15:26:52 -0600 Subject: [PATCH 6/6] add tests --- session_db/pg_session_store.py | 5 +-- session_db/tests/test_pg_session_store.py | 44 +++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/session_db/pg_session_store.py b/session_db/pg_session_store.py index 6e6430f2f2c..4abdd1e3bb8 100644 --- a/session_db/pg_session_store.py +++ b/session_db/pg_session_store.py @@ -3,6 +3,7 @@ # Copyright (c) ACSONE SA 2022 # @author Stéphane Bidoul import base64 +import binascii import json import logging import os @@ -183,8 +184,8 @@ def str_to_session(self, data): def convert(value): if isinstance(value, str) and value.startswith(self.prefix_binary): try: - return base64.b64decode(value[len(self.prefix_binary) :]) - except (ValueError, TypeError): + return base64.b64decode(value[len(self.prefix_binary):], validate=True) + except (ValueError, TypeError, binascii.Error): return value return value diff --git a/session_db/tests/test_pg_session_store.py b/session_db/tests/test_pg_session_store.py index 1bd0eb49ca2..75532268345 100644 --- a/session_db/tests/test_pg_session_store.py +++ b/session_db/tests/test_pg_session_store.py @@ -1,3 +1,4 @@ +import base64 from unittest import mock import psycopg2 @@ -92,3 +93,46 @@ def test_make_postgres_uri(self): assert "postgres://test:PASSWORD@localhost:5432/test" == _make_postgres_uri( **connection_info ) + + def test_binary_serialization_roundtrip(self): + """Ensures binary data is safely serialized to a base64 string + and accurately deserialized back to bytes.""" + original_data = { + "normal_text": "test", + "binary_data": b"Test binary", + } + serialized = self.session_store.session_to_str(original_data) + expected_b64 = base64.b64encode(b"Test binary").decode("utf-8") + self.assertEqual( + serialized["binary_data"], + f"base64::{expected_b64}", + "Binary data should be serialized with the configured prefix.", + ) + self.assertEqual(serialized["normal_text"], "test") + + deserialized = self.session_store.str_to_session(serialized) + self.assertEqual(deserialized["binary_data"], b"Test binary") + self.assertIsInstance(deserialized["binary_data"], bytes) + + def test_recursive_traversal(self): + """Verifies that base64 serialization works inside nested structures.""" + data = { + "list_of_data": [b"binary_in_list", "100", {"deep_key": b"deep_binary"}] + } + serialized = self.session_store.session_to_str(data) + self.assertTrue(serialized["list_of_data"][0].startswith("base64::")) + self.assertTrue( + serialized["list_of_data"][2]["deep_key"].startswith("base64::") + ) + + result = self.session_store.str_to_session(serialized) + self.assertEqual(result["list_of_data"][0], b"binary_in_list") + self.assertEqual(result["list_of_data"][1], "100") + self.assertEqual(result["list_of_data"][2]["deep_key"], b"deep_binary") + + def test_invalid_base64_fallback(self): + """Failsafe: Invalid base64 strings with the exact prefix must return + the original string without crashing the session load.""" + invalid_data = {"bad_binary": "base64::TESTS_INVALID_@#$"} + result = self.session_store.str_to_session(invalid_data) + self.assertEqual(result["bad_binary"], "base64::TESTS_INVALID_@#$")