Skip to content

Commit 8890e9d

Browse files
committed
fix(stepflow): mask SecretStr on the worker output edge
Stop unwrapping SecretStr fields and resolving env-var-named secrets onto the serialized output edge. model_dump(mode="json") leaves them masked, and the worker never emits the plaintext onto an edge the orchestrator routes between steps and may stream back. A component that needs the real value must resolve it on its own input/config edge inside the worker.
1 parent 55e9146 commit 8890e9d

2 files changed

Lines changed: 49 additions & 175 deletions

File tree

src/langflow-stepflow/src/langflow_stepflow/worker/handlers/base_model.py

Lines changed: 8 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
"""Handlers for Pydantic BaseModel serialization and deserialization.
22
33
Input: reconstruct BaseModel instances from dicts with ``__class_name__`` markers.
4-
Output: serialize BaseModel instances with class metadata and SecretStr handling.
4+
Output: serialize BaseModel instances with class metadata. ``SecretStr`` fields are left
5+
masked by Pydantic's ``model_dump`` -- secrets are never unwrapped onto this serialized
6+
output edge (the orchestrator routes it between steps and may stream it back).
57
"""
68

79
from __future__ import annotations
@@ -84,75 +86,14 @@ def _is_secret_str_type(field_type: Any) -> bool:
8486
return False
8587

8688

87-
def _looks_like_env_var_name(value: str) -> bool:
88-
"""Check if a string looks like an environment variable name.
89-
90-
Matches strings that are all uppercase letters/digits/underscores and
91-
contain at least one underscore, like ``OPENAI_API_KEY`` or
92-
``AWS_SECRET_KEY``. The underscore requirement avoids matching short
93-
names like ``PATH`` or ``HOME``.
94-
"""
95-
return value.isupper() and "_" in value
96-
97-
98-
def _resolve_secret_value(secret_value: Any) -> Any:
99-
"""Resolve a SecretStr value, attempting env var lookup if it looks like one.
100-
101-
Only attempts resolution when the value looks like an env var name
102-
(all uppercase with underscores) to avoid accidentally matching
103-
unrelated environment variables.
104-
"""
105-
if not isinstance(secret_value, str) or not secret_value:
106-
return secret_value
107-
108-
if not _looks_like_env_var_name(secret_value):
109-
return secret_value
110-
111-
import os
112-
113-
resolved = os.getenv(secret_value)
114-
return resolved if resolved is not None else secret_value
115-
116-
117-
def _handle_special_pydantic_types(obj: Any, serialized: dict[str, Any]) -> dict[str, Any]:
118-
"""Handle SecretStr and other special Pydantic types during serialization."""
119-
try:
120-
if hasattr(obj, "model_fields"):
121-
fields = obj.model_fields
122-
for field_name, field_info in fields.items():
123-
if hasattr(field_info, "annotation"):
124-
field_type = field_info.annotation
125-
if _is_secret_str_type(field_type):
126-
field_value = getattr(obj, field_name, None)
127-
if field_value is not None:
128-
try:
129-
secret_value = field_value.get_secret_value()
130-
serialized[field_name] = _resolve_secret_value(secret_value)
131-
except Exception:
132-
pass
133-
elif hasattr(obj, "__fields__"):
134-
fields = obj.__fields__
135-
for field_name, field_info in fields.items():
136-
field_type = field_info.type_
137-
if _is_secret_str_type(field_type):
138-
field_value = getattr(obj, field_name, None)
139-
if field_value is not None:
140-
try:
141-
secret_value = field_value.get_secret_value()
142-
serialized[field_name] = _resolve_secret_value(secret_value)
143-
except Exception:
144-
pass
145-
except Exception:
146-
pass
147-
148-
return serialized
149-
150-
15189
class BaseModelOutputHandler(OutputHandler):
15290
"""Serialize Pydantic BaseModel instances with class metadata.
15391
154-
Produces dicts with ``__class_name__`` and ``__module_name__`` markers.
155-
Includes special handling for SecretStr fields (resolves env vars).
92+
Produces dicts with ``__class_name__`` and ``__module_name__`` markers. ``SecretStr``
93+
fields stay masked: ``model_dump(mode="json")`` renders them as ``'**********'`` and we
94+
never unwrap them onto the serialized output. If a downstream component needs the real
95+
value, it must be resolved on that component's input/config edge inside the worker, not
96+
carried through the orchestrator (see follow-up for real component execution).
15697
"""
15798

15899
def matches(self, *, value: Any) -> bool:
@@ -183,8 +124,6 @@ async def process(self, value: Any) -> Any:
183124
except Exception:
184125
serialized = value.model_dump(mode="json")
185126

186-
serialized = _handle_special_pydantic_types(value, serialized)
187-
188127
serialized["__class_name__"] = value.__class__.__name__
189128
serialized["__module_name__"] = value.__class__.__module__
190129
return serialized

src/langflow-stepflow/tests/unit/test_type_converter.py

Lines changed: 41 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ async def _instantiate_component(self, component_info: dict[str, Any]) -> tuple[
5151
return component_info.get("instance"), component_info.get("name", "test")
5252

5353

54+
# Pydantic renders a non-empty SecretStr field as this mask under model_dump(mode="json").
55+
MASKED_SECRET = "**********" # pragma: allowlist secret
56+
57+
5458
class TestBaseModelOutputHandler:
5559
"""Test cases for BaseModelOutputHandler serialization."""
5660

@@ -84,8 +88,8 @@ async def test_simple_basemodel_serialization(self):
8488
assert serialized["__module_name__"] == "tests.unit.test_type_converter"
8589

8690
@pytest.mark.asyncio
87-
async def test_secret_str_serialization_with_actual_secret(self):
88-
"""Test that SecretStr fields are properly serialized with actual values."""
91+
async def test_secret_str_fields_are_masked(self):
92+
"""SecretStr fields serialize masked -- the plaintext value is never emitted."""
8993
secret_value = "test-api-key-12345" # pragma: allowlist secret
9094
model = SecretTestModel(
9195
name="test",
@@ -95,49 +99,32 @@ async def test_secret_str_serialization_with_actual_secret(self):
9599

96100
serialized = await self.handler.process(model)
97101

98-
# Check that secret values are properly extracted
99102
assert serialized["name"] == "test"
100-
assert serialized["api_key"] == secret_value # pragma: allowlist secret
101-
assert serialized["optional_secret"] == "optional-secret-value" # pragma: allowlist secret
103+
assert serialized["api_key"] == MASKED_SECRET
104+
assert serialized["optional_secret"] == MASKED_SECRET
105+
assert secret_value not in serialized.values()
102106

103-
# Check class metadata
104107
assert serialized["__class_name__"] == "SecretTestModel"
105108
assert serialized["__module_name__"] == "tests.unit.test_type_converter"
106109

107110
@pytest.mark.asyncio
108-
async def test_secret_str_serialization_with_env_var_resolution(self):
109-
"""Test that SecretStr fields with environment variable names are resolved."""
110-
# Set up environment variable
111+
async def test_env_var_name_secret_is_not_resolved(self):
112+
"""A SecretStr holding an env var name must NOT be resolved onto the output edge."""
111113
test_api_key = "actual-api-key-from-env" # pragma: allowlist secret
112114

113115
with patch.dict(os.environ, {"TEST_API_KEY": test_api_key}):
114-
# Create model with environment variable name as secret
115-
model = SecretTestModel(
116-
name="test",
117-
api_key=SecretStr("TEST_API_KEY"), # Environment variable name
118-
)
116+
model = SecretTestModel(name="test", api_key=SecretStr("TEST_API_KEY"))
119117

120118
serialized = await self.handler.process(model)
121119

122-
# Check that environment variable was resolved
123-
assert serialized["api_key"] == test_api_key
120+
# Neither the env var value nor its name should leak; the field stays masked.
121+
assert serialized["api_key"] == MASKED_SECRET
122+
assert test_api_key not in serialized.values()
124123
assert serialized["name"] == "test"
125124

126125
@pytest.mark.asyncio
127-
async def test_secret_str_serialization_no_env_var_fallback(self):
128-
"""Test SecretStr serialization when environment variable doesn't exist."""
129-
# Create model with non-existent environment variable name
130-
model = SecretTestModel(name="test", api_key=SecretStr("NON_EXISTENT_ENV_VAR"))
131-
132-
serialized = await self.handler.process(model)
133-
134-
# Should keep the original value if env var doesn't exist
135-
assert serialized["api_key"] == "NON_EXISTENT_ENV_VAR" # pragma: allowlist secret
136-
assert serialized["name"] == "test"
137-
138-
@pytest.mark.asyncio
139-
async def test_openai_embeddings_like_serialization(self):
140-
"""Test serialization of OpenAI-like embeddings model."""
126+
async def test_openai_embeddings_like_serialization_masks_key(self):
127+
"""OpenAI-like embeddings model serializes with its api key masked, others intact."""
141128
api_key = "real-openai-key-12345" # pragma: allowlist secret
142129
model = MockOpenAIEmbeddings(
143130
model="text-embedding-3-small",
@@ -148,33 +135,19 @@ async def test_openai_embeddings_like_serialization(self):
148135

149136
serialized = await self.handler.process(model)
150137

151-
# Check all fields are properly serialized
152138
assert serialized["model"] == "text-embedding-3-small"
153-
assert serialized["openai_api_key"] == api_key # SecretStr unwrapped
139+
assert serialized["openai_api_key"] == MASKED_SECRET
140+
assert api_key not in serialized.values()
154141
assert serialized["chunk_size"] == 500
155142
assert serialized["max_retries"] == 3 # Default value
156143
assert serialized["dimensions"] == 1536
157144

158-
# Check class metadata for reconstruction
159145
assert serialized["__class_name__"] == "MockOpenAIEmbeddings"
160146
assert serialized["__module_name__"] == "tests.unit.test_type_converter"
161147

162-
@pytest.mark.asyncio
163-
async def test_openai_api_key_env_var_resolution(self):
164-
"""Test that OPENAI_API_KEY environment variable is properly resolved."""
165-
real_api_key = "proj-real-openai-key-from-environment" # pragma: allowlist secret
166-
167-
with patch.dict(os.environ, {"OPENAI_API_KEY": real_api_key}):
168-
model = MockOpenAIEmbeddings(openai_api_key=SecretStr("OPENAI_API_KEY"))
169-
170-
serialized = await self.handler.process(model)
171-
172-
# Verify the actual API key value is serialized, not the env var name
173-
assert serialized["openai_api_key"] == real_api_key
174-
175148
@pytest.mark.asyncio
176149
async def test_mixed_secret_and_regular_fields(self):
177-
"""Test model with both secret and regular fields."""
150+
"""Model with both secret and regular fields: secret masked, regular fields intact."""
178151
api_key = "secret-key-value" # pragma: allowlist secret
179152
model = SecretTestModel(
180153
name="production-model",
@@ -184,38 +157,12 @@ async def test_mixed_secret_and_regular_fields(self):
184157

185158
serialized = await self.handler.process(model)
186159

187-
# Regular field should be unchanged
188160
assert serialized["name"] == "production-model"
189-
# Secret field should be unwrapped
190-
assert serialized["api_key"] == api_key
161+
assert serialized["api_key"] == MASKED_SECRET
162+
assert api_key not in serialized.values()
191163
# None secret field should remain None
192164
assert serialized["optional_secret"] is None
193165

194-
@pytest.mark.asyncio
195-
@patch.dict(os.environ, {}, clear=True) # Clear environment
196-
async def test_secret_str_with_no_env_vars(self):
197-
"""Test SecretStr serialization when no environment variables are set."""
198-
model = SecretTestModel(name="test", api_key=SecretStr("MISSING_API_KEY"))
199-
200-
serialized = await self.handler.process(model)
201-
202-
# Should keep original value if env var resolution fails
203-
assert serialized["api_key"] == "MISSING_API_KEY" # pragma: allowlist secret
204-
205-
@pytest.mark.asyncio
206-
async def test_serialized_data_structure_debugging(self):
207-
"""Test to show what serialized data looks like for debugging."""
208-
api_key = "debug-key-12345" # pragma: allowlist secret
209-
model = MockOpenAIEmbeddings(model="test-model", openai_api_key=SecretStr(api_key), chunk_size=123)
210-
211-
serialized = await self.handler.process(model)
212-
213-
# Verify the structure
214-
assert "openai_api_key" in serialized
215-
assert serialized["openai_api_key"] == api_key
216-
assert "__class_name__" in serialized
217-
assert "__module_name__" in serialized
218-
219166

220167
class TestBaseModelInputHandler:
221168
"""Test cases for BaseModelInputHandler deserialization."""
@@ -241,8 +188,13 @@ async def test_simple_basemodel_deserialization(self):
241188
assert deserialized.enabled is False
242189

243190
@pytest.mark.asyncio
244-
async def test_openai_embeddings_like_deserialization(self):
245-
"""Test deserialization of OpenAI-like embeddings model."""
191+
async def test_secret_round_trip_stays_masked(self):
192+
"""Across the worker boundary a secret survives only as its mask, not the real value.
193+
194+
Masking on the output edge is intentional (see BaseModelOutputHandler): the plaintext
195+
is never serialized, so a serialize->deserialize round trip reconstructs the masked
196+
SecretStr. A component needing the real value must resolve it on its own input edge.
197+
"""
246198
api_key = "real-openai-key-12345" # pragma: allowlist secret
247199
model = MockOpenAIEmbeddings(
248200
model="text-embedding-ada-002",
@@ -261,27 +213,10 @@ async def test_openai_embeddings_like_deserialization(self):
261213
assert deserialized.chunk_size == 750
262214
assert deserialized.max_retries == 3 # Default
263215

264-
# Check that SecretStr field is reconstructed properly
216+
# The real key never crossed the boundary; only the mask round-trips.
265217
assert isinstance(deserialized.openai_api_key, SecretStr)
266-
assert deserialized.openai_api_key.get_secret_value() == api_key
267-
268-
@pytest.mark.asyncio
269-
async def test_openai_api_key_env_var_round_trip(self):
270-
"""Test that OPENAI_API_KEY environment variable is properly resolved."""
271-
real_api_key = "proj-real-openai-key-from-environment" # pragma: allowlist secret
272-
273-
with patch.dict(os.environ, {"OPENAI_API_KEY": real_api_key}):
274-
model = MockOpenAIEmbeddings(openai_api_key=SecretStr("OPENAI_API_KEY"))
275-
276-
serialized = await self.output_handler.process(model)
277-
278-
# Test full round-trip
279-
fields = {"param": (serialized, {})}
280-
result = await self.input_handler.prepare(fields, None)
281-
282-
deserialized = result["param"]
283-
assert isinstance(deserialized.openai_api_key, SecretStr)
284-
assert deserialized.openai_api_key.get_secret_value() == real_api_key
218+
assert deserialized.openai_api_key.get_secret_value() == MASKED_SECRET
219+
assert deserialized.openai_api_key.get_secret_value() != api_key
285220

286221
def test_deserialization_without_class_metadata(self):
287222
"""Test that regular dicts without markers don't match."""
@@ -377,8 +312,8 @@ async def test_langflow_types_still_work(self):
377312
pytest.skip("Langflow not available for testing")
378313

379314
@pytest.mark.asyncio
380-
async def test_real_openai_embeddings_serialization(self):
381-
"""Test with actual OpenAI embeddings class if available."""
315+
async def test_real_openai_embeddings_serialization_masks_key(self):
316+
"""Real OpenAIEmbeddings serializes with its api key masked, not unwrapped."""
382317
try:
383318
from langchain_openai import OpenAIEmbeddings
384319
from pydantic import SecretStr
@@ -394,17 +329,18 @@ async def test_real_openai_embeddings_serialization(self):
394329
# Serialize
395330
serialized = await self.executor._apply_output_handlers(embeddings, self.handlers)
396331

397-
# Check that API key is properly extracted
332+
# The plaintext key must never reach the serialized output edge.
398333
assert "openai_api_key" in serialized
399-
assert serialized["openai_api_key"] == api_key
334+
assert serialized["openai_api_key"] == MASKED_SECRET
335+
assert api_key not in serialized.values()
400336
assert serialized["model"] == "text-embedding-3-small"
401337
assert serialized["chunk_size"] == 500
402338

403339
# Check metadata for reconstruction
404340
assert serialized["__class_name__"] == "OpenAIEmbeddings"
405341
assert "langchain_openai" in serialized["__module_name__"]
406342

407-
# Test deserialization
343+
# Round-trips as the mask, never the real key.
408344
input_handler = BaseModelInputHandler()
409345
fields = {"param": (serialized, {})}
410346
result = await input_handler.prepare(fields, None)
@@ -414,13 +350,12 @@ async def test_real_openai_embeddings_serialization(self):
414350
assert deserialized.model == "text-embedding-3-small"
415351
assert deserialized.chunk_size == 500
416352

417-
# Check that SecretStr is properly reconstructed
418353
assert hasattr(deserialized, "openai_api_key")
419354
if isinstance(deserialized.openai_api_key, SecretStr):
420-
assert deserialized.openai_api_key.get_secret_value() == api_key
355+
assert deserialized.openai_api_key.get_secret_value() == MASKED_SECRET
356+
assert deserialized.openai_api_key.get_secret_value() != api_key
421357
else:
422-
# In some versions, it might be a string after deserialization
423-
assert deserialized.openai_api_key == api_key
358+
assert deserialized.openai_api_key == MASKED_SECRET
424359

425360
except ImportError:
426361
pytest.skip("langchain_openai not available for real OpenAI embeddings test")

0 commit comments

Comments
 (0)