← Community
bugopen

Interactive egress guard fails OPEN in every caller — a raise in its redacted branch forwards unredacted PHI/PII to the provider

marcofgvmarcofgv#28d ago · 10 views
affected: station-v1.5.0

Component: the three interactive callers of station_llm.guard_egress_messages()studio_server.py:6446 (Studio chat), routes/handlers_meta.py:59 (_h_build, the MCP /build compose path), and studio_server.py:6680 (compose_raw, the Workflow-Builder compose path, introduced in v1.4.1). Present and unchanged through v1.5.0 — which edited this very guard (station_llm.py, receipt-honesty fix dc6abe) without closing the fail-open path below.

The defect.
guard_egress_messages() is the classify->policy->tokenize egress guard for the surfaces that call the model directly. Its own contract is explicit — docstring: "Fails CLOSED: if a policy is configured and the guard raises, we deny rather than leak" — and its internal exception branches were hardened against exactly this (the import-error and classifier-error paths now return ("denied", …) when an egress policy is configured; 27fecd / 41b195, dinkarshweta). Returning "denied" is how it fails closed.

But the guard can still RAISE, and the callers turn that raise back into a leak. The redacted branch runs two calls that are NOT inside any try:

if decision == "redacted":
hmac_key = egress_receipt.install_hmac_key() # not guarded — can raise
fwd, tmap = _et.tokenize_messages(messages, hmac_key, # not guarded — can raise
list(counts.keys()), _redaction_patterns())
return ("redacted", fwd, tmap, reason)

This branch is reached on the one path that matters: sensitive identifiers were detected AND policy says redact. If install_hmac_key() or tokenize_messages() raises there, the exception propagates OUT of the guard — it does not return "denied". And install_hmac_key() is documented to raise: "RAISES if no seed can be created (e.g. permission denied on the vault path). Fail closed — a receipt with no HMAC is worse than a boot-time failure." So on any install where WS/the vault path is not writable, the guard raises before it can return "denied".

Every caller wraps the guard in try / except Exception and, on exception, proceeds to the provider with the ORIGINAL, UNREDACTED messages:

# compose_raw (studio_server.py:6680)
try:
_dec, messages, _map, _why = _sllm.guard_egress_messages(messages, prov, module_id="studio_builder")
if _dec == "denied":
return ""
except Exception:
_map = None
# ...falls straight through to CLOUD dispatch below, sending the raw messages
# to groq / openai / anthropic / xai on the user's own key.

# Studio chat (studio_server.py:6446)
except Exception:
_egress_map = None
out = groq_chat(messages) # raw messages

# MCP /build (routes/handlers_meta.py:59)
except Exception:
return groq_raw(msgs) # raw msgs, explicitly

So a guard exception in the redacted branch — precisely when redaction was required — is silently converted by the caller into "send the clear text." The guard's "fails CLOSED" guarantee holds only while the guard RETURNS; the moment it RAISES, all three interactive surfaces fail OPEN and leak. Nothing downstream contains it — groq_raw/groq_chat POST the messages to the provider with no redaction of their own (running that redaction is the guard's entire job).

Version history: compose_raw was introduced in v1.4.1, and its own comment names the threat — "the same PHI-egress bypass class as 27fecd. Don't reintroduce it: resolve here, dispatch after the guard." It does dispatch after the guard, but its except Exception: _map = None fall-through reintroduces the exact leak on the guard's exception path. v1.5.0 then edited this same guard (station_llm.py, dc6abe — it now records decision="redaction_no_op" when the token map is empty, a receipt-honesty fix) but left the un-try'd install_hmac_key() / tokenize_messages() in the redacted branch and all three callers' fail-open except untouched, so the leak stands in the current release.

Reproduction (standalone; workbench/ on sys.path). Drives the real MCP /build caller (_h_build), forces the guard to raise the way its un-try'd redacted branch does, and captures what the provider actually received.

import station_llm as sllm
import routes.handlers_meta as hm

SENSITIVE = [{"role": "user",
"content": "compose a flow to email patient john.doe@acme.com SSN 123-45-6789"}]

# 1) guard RAISES on the redacted path (sensitive present + tokenize/install_hmac_key failure)
def boom(messages, provider, module_id=None):
raise RuntimeError("install_hmac_key failed") # what the un-try'd redacted branch does
sllm.guard_egress_messages = boom

# 2) capture exactly what reaches the provider; stub compose_spec to invoke the guarded callable once
seen = {}
hm.groq_raw = lambda msgs: seen.setdefault("sent", msgs) or "{}"
hm.catalog = lambda: {}
hm.compose_engine.compose_spec = lambda msgs, cat, guarded_raw: (guarded_raw(msgs), None)[1]

try:
hm._h_build({"messages": SENSITIVE}, "stamp") # raises "could not compose" after the leak
except Exception:
pass

assert "123-45-6789" in str(seen.get("sent")), "raw SSN did NOT reach provider"
print("LEAK: unredacted PHI/PII forwarded on guard exception ->", seen["sent"])

Expected output: the assert passes — the raw SSN/email reached groq_raw even though a redaction policy was in force and the guard failed. compose_raw (Builder) and the Studio-chat caller carry the same except Exception: branch that sends messages unchanged, so the leak is identical on all three surfaces.

Expected (correct) behavior: a guard EXCEPTION must be treated like "denied" when an egress policy is configured — the caller must NOT call the provider — matching the guard's own fail-closed contract. Today only the returned "denied" is honored; the raised exception is not.

Scope. Reached whenever an egress policy is configured (so redaction is expected) and the guard's redacted branch raises — e.g. an HMAC-key install/permission error or a tokenizer failure on the message content. No network precondition beyond a normal Builder-compose / Studio-chat request that carries redactable content. The leak is the full unredacted message set to the selected cloud provider (groq/openai/anthropic/xai) on the user's own key. Not triggered for the local Ollama branch (no egress) or when no policy is configured (the documented dev carve-out).

Fix. Fail closed at every call site — deny on exception, don't fall through:

try:
_dec, messages, _map, _why = _sllm.guard_egress_messages(messages, prov, module_id="studio_builder")
if _dec == "denied":
return ""
except Exception as e:
# guard raised -> we cannot prove the content is safe -> treat as denied,
# same rule the guard uses internally (27fecd/#22). Never send raw.
globals()["_LAST_COMPOSE_ERROR"] = "egress guard error, refusing to send: %s" % str(e)[:160]
return ""

Apply the identical change to studio_server.py:6446 (return an egress-denied reply) and routes/handlers_meta.py:59 (return "" instead of groq_raw(msgs)). Alternatively, wrap the guard's own redacted branch (install_hmac_key / tokenize_messages) in the same deny-on-error try the other branches already have — but the callers must still fail closed, since a bare except around a security control that then proceeds is the root pattern.

Classification: CWE-755 → CWE-359

0 replies

Sign in to reply.