Skip to content

Commit cc304e0

Browse files
fix(templates): unwrap SecretStr only on persistence path, not API responses
PR #744 stopped the SecretStr-not-JSON-serializable crash on POST/PUT /templates by switching the configurations dump to mode="json". That stops the crash, but Pydantic's default JSON serialization for SecretStr is the masked form ("**********") — silently corrupting the persisted token. At call time the loaded template then sends `Authorization: Bearer **********` to the upstream MCP / HTTP endpoint, which fails authentication. Fix is in two parts: 1. HttpAuthConfig.field_serializer (template/types.py) Reveals the underlying string for the three SecretStr fields (token, password, api_key_value) **only when** model_dump is invoked with context={"reveal_secrets": True}. Without the context flag the serializer falls back to the masked "**********" form. So: - Persistence paths (create_template_handler, replace_template_handler) pass the context and write the real value (typically a `{credential_name}` placeholder resolved per call from the credentials table). - All other JSON serialization paths — notably FastAPI response encoding for GET /templates/{id} and the PUT response — see no context, fall through to the masked form, and so do not leak literal tokens that an operator may have embedded directly. This addresses the AI review note on the original PR (mask_template_secrets() only masks template.secrets, not template.configurations, so the Pydantic-level mask was the only guard for configurations.mcp.servers[*].auth.token in API responses). 2. template/cache.py — also passes reveal_secrets context The Redis L2 template cache calls model_dump_json() to populate itself. Without the context flag it would serialize the auth token as "**********" and on cache HIT reconstruct the template with auth.token = SecretStr("**********") — runtime would then send `Authorization: Bearer **********` to the MCP server. Reproduced live in chat: turn 1 (cache miss) succeeded because MCP setup ran before the cache write, but turn 2+ hit the poisoned cache and returned `web_search_exa error (401): Invalid API key` from Exa. The cache is internal/private; the masking only belongs in API responses, where the lack of context preserves it. mode="python" (the default) keeps the SecretStr instance, so in-memory access via `.get_secret_value()` on the runtime path is unaffected. Verified end-to-end against the running local server, six cases: Persistence + API response (HTTP layer) 1. Placeholder template POST -> DB has "{exa_api_key}" literal, GET API response auth.token = "**********". 2. Literal-token template POST with token="sk-LITERAL-LEAK-CANARY-12345" -> DB has the literal canary, GET API response auth.token = "**********", canary string occurs zero times in the response body (full-body grep). 3. PUT replace path: PUT with token="sk-PUT-CANARY-67890" -> DB has the new literal canary, PUT response masked, canary occurs zero times in response body. 4. Test rows cleaned up. Redis cache + chat runtime (cache.py fix) 5. Busted local Redis cache for chat-direct-claude template, ran two-turn chat session via the demo session API. Both turns show `auth-fingerprint=resolved-len=43-tail=0556fe` (the real Exa key is 36 chars, so "Bearer " + 36 = 43; tail matches the real key suffix). Cache HIT (turn 2) preserves the placeholder. 6. Turn 2 prompt asked the LLM to web-search Strait of Hormuz news; web_search_exa was invoked, returned real Reuters / India trade news content with 7 cited URLs in the assistant reply; zero `Invalid API key` strings in the response. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7bd7f0f commit cc304e0

3 files changed

Lines changed: 54 additions & 9 deletions

File tree

app/ai/voice/agents/breeze_buddy/template/cache.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,18 @@ async def get_template_by_id_cached(template_id: str) -> Optional[TemplateModel]
6363
return None
6464

6565
try:
66+
# ``reveal_secrets`` lets HttpAuthConfig's field_serializer (see
67+
# template/types.py) emit the underlying string for
68+
# ``token``/``password``/``api_key_value`` rather than the masked
69+
# ``"**********"`` form. Without it, cache writes would store a
70+
# masked auth value, and the next cache HIT would reconstruct
71+
# ``SecretStr("**********")`` — runtime would then send
72+
# ``Authorization: Bearer **********`` to the upstream server.
73+
# The Redis cache is internal/private; the masking only belongs
74+
# in API responses, where the lack of context preserves it.
6675
await redis.setex(
6776
cache_key,
68-
template.model_dump_json(),
77+
template.model_dump_json(context={"reveal_secrets": True}),
6978
ttl_seconds=CACHE_TTL_SECONDS,
7079
)
7180
logger.debug(f"template cache populated: {template_id}")

app/ai/voice/agents/breeze_buddy/template/types.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
ConfigDict,
1111
Field,
1212
SecretStr,
13+
SerializationInfo,
14+
field_serializer,
1315
model_validator,
1416
)
1517

@@ -1050,6 +1052,28 @@ class HttpAuthConfig(BaseModel):
10501052
api_key_name: Optional[str] = None # Header name for API key
10511053
api_key_value: Optional[SecretStr] = None # API key value
10521054

1055+
# Templates are persisted to Postgres as JSON. Pydantic's default
1056+
# JSON serialization for SecretStr produces the masked form
1057+
# ("**********"), which silently corrupts the stored value — the
1058+
# template would then send `Authorization: Bearer **********` at
1059+
# call time. We unmask **only** when an explicit
1060+
# ``context={"reveal_secrets": True}`` flag is passed to model_dump
1061+
# (the create/replace handlers do this on the persistence path).
1062+
# All other JSON serialization paths — notably FastAPI response
1063+
# encoding for GET / PUT — see no context, fall through to the
1064+
# masked form, and so do not leak literal tokens that an operator
1065+
# may have embedded directly (vs. the intended
1066+
# ``{credential_name}`` placeholder pattern).
1067+
@field_serializer("token", "password", "api_key_value", when_used="json")
1068+
def _reveal_secret(
1069+
self, value: Optional[SecretStr], info: SerializationInfo
1070+
) -> Optional[str]:
1071+
if value is None:
1072+
return None
1073+
if info.context and info.context.get("reveal_secrets"):
1074+
return value.get_secret_value()
1075+
return "**********"
1076+
10531077

10541078
class HttpRequestConfig(BaseModel):
10551079
"""Complete HTTP request configuration for hooks and global functions.

app/api/routers/breeze_buddy/templates/handlers.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,19 @@ async def create_template_handler(
111111
now = datetime.now(timezone.utc)
112112

113113
# Build configurations dict from the ConfigurationModel.
114-
# mode="json" unwraps SecretStr fields (e.g. mcp.servers[*].auth.token)
115-
# to their string value so the downstream json.dumps in
116-
# ``create_template`` doesn't choke on a SecretStr instance.
114+
# mode="json" + reveal_secrets context unwraps SecretStr fields
115+
# (e.g. mcp.servers[*].auth.token) to their underlying string so
116+
# the downstream json.dumps in ``create_template`` writes the
117+
# real value (typically a ``{credential_name}`` placeholder).
118+
# Without the context flag, HttpAuthConfig's serializer falls
119+
# back to the masked "**********" form, which is what we want
120+
# everywhere except this persistence path.
117121
configurations = None
118122
if template_data.configurations:
119123
configurations = template_data.configurations.model_dump(
120-
exclude_none=True, mode="json"
124+
exclude_none=True,
125+
mode="json",
126+
context={"reveal_secrets": True},
121127
)
122128

123129
template = await create_template(
@@ -404,13 +410,19 @@ async def replace_template_handler(
404410
now = datetime.now(timezone.utc)
405411

406412
# Build configurations dict from the ConfigurationModel.
407-
# mode="json" unwraps SecretStr fields (e.g. mcp.servers[*].auth.token)
408-
# to their string value so the downstream json.dumps in
409-
# ``replace_template`` doesn't choke on a SecretStr instance.
413+
# mode="json" + reveal_secrets context unwraps SecretStr fields
414+
# (e.g. mcp.servers[*].auth.token) to their underlying string so
415+
# the downstream json.dumps in ``replace_template`` writes the
416+
# real value (typically a ``{credential_name}`` placeholder).
417+
# Without the context flag, HttpAuthConfig's serializer falls
418+
# back to the masked "**********" form, which is what we want
419+
# everywhere except this persistence path.
410420
configurations = None
411421
if template_data.configurations:
412422
configurations = template_data.configurations.model_dump(
413-
exclude_none=True, mode="json"
423+
exclude_none=True,
424+
mode="json",
425+
context={"reveal_secrets": True},
414426
)
415427

416428
# Merge secrets: preserve **** values from existing, update real values

0 commit comments

Comments
 (0)