diff --git a/src/WebAppDIRAC/WebApp/handler/UPHandler.py b/src/WebAppDIRAC/WebApp/handler/UPHandler.py index 0afccdf15..341f1019b 100644 --- a/src/WebAppDIRAC/WebApp/handler/UPHandler.py +++ b/src/WebAppDIRAC/WebApp/handler/UPHandler.py @@ -33,14 +33,26 @@ def _safeDecode(data): raise ValueError(f"Decompressed data exceeds size limit ({_MAX_DECODED_SIZE} bytes)") try: - return json.loads(raw) + decoded = json.loads(raw) except (json.JSONDecodeError, UnicodeDecodeError): # Legacy data encoded with DEncode - validate depth before decoding _checkDEncodeDepth(raw) decoded = DEncode.decode(raw)[0] if not isinstance(decoded, (dict, list, str, int, float, bool, type(None))): raise ValueError("Stored data contains unsupported types after DEncode decode") - return decoded + + # Older encodings can wrap the actual JSON document in an extra string + # layer: legacy data is stored as a DEncode-encoded JSON text, and data + # saved between v6.0.7 and v6.0.8 was double-encoded (json.dumps() was + # applied to an already serialized JSON text). Unwrap these string + # layers, mirroring the historic json.loads(DEncode.decode(...)[0]) + # behavior, until a real structure or a plain non-JSON string is reached. + while isinstance(decoded, str): + try: + decoded = json.loads(decoded) + except (json.JSONDecodeError, UnicodeDecodeError): + break + return decoded def _checkDEncodeDepth(data): @@ -129,8 +141,11 @@ def web_saveAppState(self, obj, app, name, state): :return: dict """ up = UserProfileClient(f"Web/{obj}/{app}") - # Use JSON instead of DEncode for security and portability - data = base64.b64encode(zlib.compress(json.dumps(state).encode(), 9)) + # The client sends the state as an already serialized JSON text + # (Ext.JSON.encode on the browser side). Store it as is; calling + # json.dumps() on it would double-encode it. + raw = state if isinstance(state, str) else json.dumps(state) + data = base64.b64encode(zlib.compress(raw.encode(), 9)) # before we save the state (modify the state) we have to remember the actual access: ReadAccess and PublishAccess result = up.getVarPermissions(name) access = { diff --git a/tests/Test_UPHandler.py b/tests/Test_UPHandler.py new file mode 100644 index 000000000..010e1bda9 --- /dev/null +++ b/tests/Test_UPHandler.py @@ -0,0 +1,73 @@ +"""Regression tests for the state (de)serialization in UPHandler. + +Desktops and application states have been stored in several formats over +time: DEncode-encoded JSON text (up to v6.0.6), double-encoded JSON +(v6.0.7/v6.0.8, json.dumps applied to an already serialized JSON text) and +plain JSON text (current format). All of them must decode back to the +original structure, otherwise the frontend fails to build the "My Desktops" +tree (https://github.com/DIRACGrid/WebAppDIRAC issues from v6.0.7 release). +""" + +import base64 +import json +import sys +import zlib +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from WebAppDIRAC.WebApp.handler.UPHandler import _safeDecode # noqa: E402 + +STATE = { + "version": 2, + "data": [{"module": "DIRACSomeApp", "name": "a state"}], + "views": {"tabs": {"activeTab": 0}}, +} + + +def _compress(raw): + return base64.b64encode(zlib.compress(raw, 9)) + + +def test_decodeLegacyDEncodeEncodedJSON(): + # States saved before v6.0.7: DEncode.encode() of the JSON text + from DIRAC.Core.Utilities import DEncode + + blob = _compress(DEncode.encode(json.dumps(STATE))) + assert _safeDecode(blob) == STATE + + +def test_decodeDoubleEncodedJSON(): + # States saved with v6.0.7/v6.0.8: json.dumps() of the JSON text + blob = _compress(json.dumps(json.dumps(STATE)).encode()) + assert _safeDecode(blob) == STATE + + +def test_decodeCurrentFormat(): + # Current format: the JSON text sent by the browser, stored as is + blob = _compress(json.dumps(STATE).encode()) + assert _safeDecode(blob) == STATE + + +def test_decodePlainString(): + # Data that is neither JSON nor DEncode is rejected + blob = _compress(b"a plain string") + with pytest.raises(ValueError): + _safeDecode(blob) + + +def test_decodeJSONStringState(): + # A state that legitimately is a JSON string is unwrapped to that string + blob = _compress(json.dumps(json.dumps("just a string")).encode()) + assert _safeDecode(blob) == "just a string" + + +def test_decodeDeeplyNestedDENcodeRejected(): + # Defense-in-depth from the JSON migration must be kept: deeply nested + # legacy DEncode data is rejected instead of being decoded + nested = b"l" * 100 + b"e" * 100 # 100 nested empty lists + blob = _compress(nested) + with pytest.raises(ValueError): + _safeDecode(blob)