Skip to content

Commit afabcd7

Browse files
committed
fix: sanitize environment document cache payload to prevent internal data leak
1 parent 67fa5fd commit afabcd7

2 files changed

Lines changed: 59 additions & 6 deletions

File tree

api/environments/models.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from copy import deepcopy
55
from typing import TYPE_CHECKING, Literal
66

7+
from flagsmith_schemas.api import V1EnvironmentDocumentResponse
78
from common.core.utils import using_database_replica
89
from django.conf import settings
910
from django.contrib.contenttypes.fields import GenericRelation
@@ -359,12 +360,31 @@ def write_environment_documents(
359360
settings.CACHE_ENVIRONMENT_DOCUMENT_MODE
360361
== EnvironmentDocumentCacheMode.PERSISTENT
361362
):
362-
environment_document_cache.set_many(
363-
{
364-
e.api_key: map_environment_to_environment_document(e)
365-
for e in environments
363+
cache_payload = {}
364+
for e in environments:
365+
fat_doc = map_environment_to_environment_document(e)
366+
367+
# Safely check for Pydantic V2's model_dump, with a fallback
368+
if hasattr(fat_doc, "model_dump"):
369+
doc_dict = fat_doc.model_dump()
370+
elif hasattr(fat_doc, "dict"):
371+
# Just in case Flagsmith has a legacy custom class here
372+
doc_dict = fat_doc.dict()
373+
else:
374+
doc_dict = fat_doc
375+
376+
# Manually extract ONLY the keys allowed by the V1 TypedDict
377+
clean_doc: V1EnvironmentDocumentResponse = {
378+
"api_key": doc_dict.get("api_key"),
379+
"feature_states": doc_dict.get("feature_states", []),
380+
"identity_overrides": doc_dict.get("identity_overrides", []),
381+
"name": doc_dict.get("name"),
382+
"project": doc_dict.get("project"),
366383
}
367-
)
384+
cache_payload[e.api_key] = clean_doc
385+
386+
# Save the clean payload to Redis
387+
environment_document_cache.set_many(cache_payload)
368388

369389
def get_feature_state(
370390
self,

api/tests/unit/environments/test_unit_environments_models.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
from core.constants import STRING
2121
from core.request_origin import RequestOrigin
2222
from environments.identities.models import Identity
23+
from environments.enums import EnvironmentDocumentCacheMode
24+
from flagsmith_schemas.api import V1EnvironmentDocumentResponse
2325
from environments.metrics import CACHE_HIT, CACHE_MISS
2426
from environments.models import (
2527
Environment,
@@ -1267,7 +1269,7 @@ def test_environment_clone_from_non_versioned_environment_with_use_v2_feature_ve
12671269
# When
12681270
new_environment = environment.clone(name="new-environment")
12691271

1270-
# Then
1272+
# Then
12711273
assert new_environment.use_v2_feature_versioning
12721274

12731275
# we only expect a single environment feature version as we are essentially
@@ -1280,3 +1282,34 @@ def test_environment_clone_from_non_versioned_environment_with_use_v2_feature_ve
12801282
latest_feature_states = get_environment_flags_queryset(new_environment)
12811283
assert latest_feature_states.count() == 2
12821284
assert {fs.environment_feature_version for fs in latest_feature_states} == {efv}
1285+
1286+
@mock.patch("environments.models.environment_document_cache")
1287+
def test_write_environment_documents_strips_internal_fields_from_cache(
1288+
mock_document_cache: MagicMock,
1289+
environment: Environment,
1290+
settings: typing.Any
1291+
) -> None:
1292+
# 1. SETUP: Force the persistent caching mode
1293+
settings.CACHE_ENVIRONMENT_DOCUMENT_MODE = EnvironmentDocumentCacheMode.PERSISTENT
1294+
1295+
# 2. ACTION: Trigger the document builder
1296+
Environment.write_environment_documents(environment_id=environment.id)
1297+
1298+
# 3. INTERCEPT: Grab the exact dictionary the code tried to save to Redis
1299+
# call_args[0][0] gets the dictionary passed into set_many()
1300+
cache_payload = mock_document_cache.set_many.call_args[0][0]
1301+
cached_document = cache_payload[environment.api_key]
1302+
1303+
# 4. ASSERT: The internal/dangerous fields MUST NOT be in the payload
1304+
assert "compress_dynamo_documents" not in cached_document
1305+
assert "use_v2_feature_versioning" not in cached_document
1306+
1307+
# 5. SCHEMA VALIDATION: Prove the dictionary perfectly matches the TypedDict
1308+
# required by the API. If this raises an error, the fix failed.
1309+
clean_doc: V1EnvironmentDocumentResponse = {
1310+
"api_key": cached_document["api_key"],
1311+
"feature_states": cached_document["feature_states"],
1312+
"identity_overrides": cached_document["identity_overrides"],
1313+
"name": cached_document["name"],
1314+
"project": cached_document["project"],
1315+
}

0 commit comments

Comments
 (0)