Skip to content

Commit c979c61

Browse files
fix(core, langchain): harden load() against untrusted manifests (#37197)
1 parent d703110 commit c979c61

8 files changed

Lines changed: 550 additions & 98 deletions

File tree

libs/core/langchain_core/load/_validation.py

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
NOT instantiated as LC objects.
1919
"""
2020

21-
from typing import Any
21+
from typing import Any, cast
2222

2323
from langchain_core.load.serializable import (
2424
Serializable,
@@ -102,16 +102,25 @@ def _serialize_value(obj: Any) -> Any:
102102
return to_json_not_implemented(obj)
103103

104104

105-
def _is_lc_secret(obj: Any) -> bool:
106-
"""Check if an object is a LangChain secret marker."""
107-
expected_num_keys = 3
108-
return (
109-
isinstance(obj, dict)
110-
and obj.get("lc") == 1
111-
and obj.get("type") == "secret"
112-
and "id" in obj
113-
and len(obj) == expected_num_keys
114-
)
105+
def _get_secret_keys(obj: Serializable) -> set[str]:
106+
"""Return the merged set of constructor kwarg names declared as secrets.
107+
108+
Mirrors the MRO walk in `Serializable.to_json` so the keys returned here
109+
match the keys whose values `_replace_secrets` rewrites into secret
110+
markers. Used by `_serialize_lc_object` to decide which kwargs to skip
111+
when escaping user data.
112+
"""
113+
secrets: dict[str, str] = {}
114+
model_fields = type(obj).model_fields
115+
for cls in [None, *obj.__class__.mro()]:
116+
if cls is Serializable:
117+
break
118+
this = cast("Serializable", obj if cls is None else super(cls, obj))
119+
secrets.update(this.lc_secrets)
120+
for key in list(secrets):
121+
if (key in model_fields) and (alias := model_fields[key].alias) is not None:
122+
secrets[alias] = secrets[key]
123+
return set(secrets)
115124

116125

117126
def _serialize_lc_object(obj: Any) -> dict[str, Any]:
@@ -124,21 +133,29 @@ def _serialize_lc_object(obj: Any) -> dict[str, Any]:
124133
The serialized dict with user data in kwargs escaped as needed.
125134
126135
Note:
127-
Kwargs values are processed with `_serialize_value` to escape user data (like
128-
metadata) that contains `'lc'` keys. Secret fields (from `lc_secrets`) are
129-
skipped because `to_json()` replaces their values with secret markers.
136+
Kwargs values are processed with `_serialize_value` to escape user data
137+
(like metadata) that contains `'lc'` keys. Secret fields are identified
138+
by the class's declared `lc_secrets` and skipped because `to_json()`
139+
already converted their values to secret markers.
140+
141+
The check is key-based rather than shape-based. A shape-based check
142+
("this dict looks like a secret marker") can be forged by user data,
143+
letting attacker-controlled free-form dicts bypass escaping and reach
144+
the Reviver.
130145
"""
131146
if not isinstance(obj, Serializable):
132147
msg = f"Expected Serializable, got {type(obj)}"
133148
raise TypeError(msg)
134149

135150
serialized: dict[str, Any] = dict(obj.to_json())
136151

137-
# Process kwargs to escape user data that could be confused with LC objects
138-
# Skip secret fields - to_json() already converted them to secret markers
152+
# Process kwargs to escape user data that could be confused with LC objects.
153+
# Skip kwargs declared as secrets - `to_json()` already replaced their
154+
# values with secret markers via `_replace_secrets`.
139155
if serialized.get("type") == "constructor" and "kwargs" in serialized:
156+
secret_keys = _get_secret_keys(obj)
140157
serialized["kwargs"] = {
141-
k: v if _is_lc_secret(v) else _serialize_value(v)
158+
k: v if k in secret_keys else _serialize_value(v)
142159
for k, v in serialized["kwargs"].items()
143160
}
144161

0 commit comments

Comments
 (0)