Skip to content

Commit 96cfe0f

Browse files
committed
[FIX] session_db: support bytes serialization in JSON payload
When a custom module temporarily stores binary data (like images) in the user's session, `session_db` crashes with a `TypeError: Object of type bytes is not JSON serializable`. This happens because `session_db` explicitly calls `json.dumps()` to store the session payload in PostgreSQL. - Introduced a recursive helper `_traverse_and_convert` using dictionary comprehensions to traverse the session payload safely. - Binary values (`bytes`) are converted to base64 strings with a `base64::` prefix before JSON serialization (`session_to_str`). - When loading the session from the DB, `str_to_session` identifies the prefix and converts the string back to `bytes`.
1 parent 439ae7a commit 96cfe0f

File tree

2 files changed

+89
-1
lines changed

2 files changed

+89
-1
lines changed

session_db/pg_session_store.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
# @author Nicolas Seinlet
33
# Copyright (c) ACSONE SA 2022
44
# @author Stéphane Bidoul
5+
import base64
6+
import binascii
57
import json
68
import logging
79
import os
@@ -66,6 +68,7 @@ def __init__(self, uri, session_class=None):
6668
self._cr = None
6769
self._open_connection()
6870
self._setup_db()
71+
self.prefix_binary = "base64::"
6972

7073
def __del__(self):
7174
self._close_connection()
@@ -108,7 +111,8 @@ def _setup_db(self):
108111
@with_lock
109112
@with_cursor
110113
def save(self, session):
111-
payload = json.dumps(dict(session))
114+
json_session = self.session_to_str(dict(session))
115+
payload = json.dumps(json_session)
112116
self._cr.execute(
113117
"""
114118
INSERT INTO http_sessions(sid, write_date, payload)
@@ -131,6 +135,7 @@ def get(self, sid):
131135
self._cr.execute("SELECT payload FROM http_sessions WHERE sid=%s", (sid,))
132136
try:
133137
data = json.loads(self._cr.fetchone()[0])
138+
data = self.str_to_session(data)
134139
except Exception:
135140
return self.new()
136141

@@ -149,6 +154,45 @@ def vacuum(self, max_lifetime=http.SESSION_LIFETIME):
149154
(f"{max_lifetime} seconds",),
150155
)
151156

157+
def _traverse_and_convert(self, data_node, conversion_func):
158+
"""
159+
Recursively applies a conversion function to all elements in dicts and lists.
160+
"""
161+
if isinstance(data_node, dict):
162+
return {
163+
self._traverse_and_convert(
164+
key, conversion_func
165+
): self._traverse_and_convert(value, conversion_func)
166+
for key, value in data_node.items()
167+
}
168+
if isinstance(data_node, list):
169+
return [
170+
self._traverse_and_convert(item, conversion_func) for item in data_node
171+
]
172+
173+
return conversion_func(data_node)
174+
175+
def session_to_str(self, data):
176+
def convert(value):
177+
if isinstance(value, bytes):
178+
return self.prefix_binary + base64.b64encode(value).decode("utf-8")
179+
return value
180+
181+
return self._traverse_and_convert(data, convert)
182+
183+
def str_to_session(self, data):
184+
def convert(value):
185+
if isinstance(value, str) and value.startswith(self.prefix_binary):
186+
try:
187+
return base64.b64decode(
188+
value[len(self.prefix_binary) :], validate=True
189+
)
190+
except (ValueError, TypeError, binascii.Error):
191+
return value
192+
return value
193+
194+
return self._traverse_and_convert(data, convert)
195+
152196

153197
_original_session_store = http.root.__class__.session_store
154198

session_db/tests/test_pg_session_store.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import base64
12
from unittest import mock
23

34
import psycopg2
@@ -92,3 +93,46 @@ def test_make_postgres_uri(self):
9293
assert "postgres://test:PASSWORD@localhost:5432/test" == _make_postgres_uri(
9394
**connection_info
9495
)
96+
97+
def test_binary_serialization_roundtrip(self):
98+
"""Ensures binary data is safely serialized to a base64 string
99+
and accurately deserialized back to bytes."""
100+
original_data = {
101+
"normal_text": "test",
102+
"binary_data": b"Test binary",
103+
}
104+
serialized = self.session_store.session_to_str(original_data)
105+
expected_b64 = base64.b64encode(b"Test binary").decode("utf-8")
106+
self.assertEqual(
107+
serialized["binary_data"],
108+
f"base64::{expected_b64}",
109+
"Binary data should be serialized with the configured prefix.",
110+
)
111+
self.assertEqual(serialized["normal_text"], "test")
112+
113+
deserialized = self.session_store.str_to_session(serialized)
114+
self.assertEqual(deserialized["binary_data"], b"Test binary")
115+
self.assertIsInstance(deserialized["binary_data"], bytes)
116+
117+
def test_recursive_traversal(self):
118+
"""Verifies that base64 serialization works inside nested structures."""
119+
data = {
120+
"list_of_data": [b"binary_in_list", "100", {"deep_key": b"deep_binary"}]
121+
}
122+
serialized = self.session_store.session_to_str(data)
123+
self.assertTrue(serialized["list_of_data"][0].startswith("base64::"))
124+
self.assertTrue(
125+
serialized["list_of_data"][2]["deep_key"].startswith("base64::")
126+
)
127+
128+
result = self.session_store.str_to_session(serialized)
129+
self.assertEqual(result["list_of_data"][0], b"binary_in_list")
130+
self.assertEqual(result["list_of_data"][1], "100")
131+
self.assertEqual(result["list_of_data"][2]["deep_key"], b"deep_binary")
132+
133+
def test_invalid_base64_fallback(self):
134+
"""Failsafe: Invalid base64 strings with the exact prefix must return
135+
the original string without crashing the session load."""
136+
invalid_data = {"bad_binary": "base64::TESTS_INVALID_@#$"}
137+
result = self.session_store.str_to_session(invalid_data)
138+
self.assertEqual(result["bad_binary"], "base64::TESTS_INVALID_@#$")

0 commit comments

Comments
 (0)