Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions src/WebAppDIRAC/WebApp/handler/UPHandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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 = {
Expand Down
73 changes: 73 additions & 0 deletions tests/Test_UPHandler.py
Original file line number Diff line number Diff line change
@@ -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)
Loading