← Community
bugopen

Egress policy fails open on protective rules: an unrecognized condition (typo/newer op) makes a 'denied' rule silently allow the PII

marcofgvmarcofgv#215d ago · 97 views
affected: station-v0.93

Affected: station-v0.93 (current)

Station: verified on station-v0.93 (current). File: primitives/egress_policy.py_atom_matches() (175-233) and _load_policy() (126-174). Class: incorrect authorization / fail-open in a security control (CWE-636 "not failing securely" / CWE-863).

The control

egress_policy decides, per outbound LLM call, whether classified PII/PHI is allow / would_redact / would_approve / denied (the operator's explicit "do not send this to the provider"). Rules are {"if": <condition>, "then": <decision>}, evaluated top-down, first match wins, else the doc default.

The break — "malformed → False" is fail-safe for allow rules but fail-OPEN for deny rules

_atom_matches() ends with return False for any unrecognized atomic operator key (line 233), and its docstring calls this "fail-safe: unknown rule shape should not accidentally trigger" (195-197). That reasoning is backwards for a protective rule. Since evaluation is first-match-wins with a permissive default (typically allow), any protective rule — denied, would_deny, would_redact, would_approve — that fails to match falls through to that default. So an unrecognized condition silently disables the protection: a denied rule stops denying (PII forwarded), a would_redact rule stops redacting (PII forwarded unredacted). The default: allow deployment is where "no-match" becomes "send it." (Scope note: this is the unknown atomic key path; compounds differ — not of an unknown child evaluates True, an empty all is True — and a wrong operand type may raise rather than return False. The demonstrated, common case is the unknown/typo'd atomic key.)

Two conditions reach this path — one demonstrated, one plausible: a typo in an operator key (has_categoy, demonstrated below), and a newer-schema operator a rule was written for but this evaluator predates (forward-incompatibility in a mixed-version deployment; plausible, not demonstrated here). And the policy loader/evaluator does no condition validation_load_policy just loads JSON; nothing here rejects an unknown-operator rule, so it is accepted and silently inert (a separate authoring workflow, if any, was not examined). Separate related loader facet: bundle files that "don't exist or fail to parse are silently skipped (fail-open to allow)" (loader docstring), dropping any deny rules they carry.

Proof (container, real egress_policy.evaluate, v0.93; classification = {card_like: 1})

correct deny rule {has_category: card_like} → 'denied'                              (works)
deny rule with a TYPO'd op {has_categoy: card_like} → 'allow' (fell to default)     FAIL-OPEN
deny rule with an unknown/future op {category_matches_regex: card} → 'allow'        FAIL-OPEN
egress_policy has a rule validator: False   (only _load_policy; no condition validation)

In cases 2 and 3 the operator authored a rule meaning "deny credit-card-shaped content to this provider"; the card-shaped identifier is classified (card_like: 1) yet the policy resolves to allow. The demonstrated result is the allow decision; the disclosure follows when that decision lets the outbound call proceed to the provider carrying the classified data (station_llm's egress path).

Impact

The one egress decision an operator most needs to be reliable — denied — silently does nothing whenever its condition isn't recognized, with no author-time validation to warn them. The operator sees a deny rule in their policy and believes the egress is blocked; it is not. Distinct from an allow/approve rule, which fails safe.

Honest scope

  • Requires a protective rule (denied/would_deny/would_redact/would_approve) whose atomic condition the evaluator doesn't recognize — a typo (demonstrated) or a forward-version operator (plausible). Only protective rules are harmed: an allow rule failing to match merely falls to the same permissive default.
  • Not a remote/unauthenticated bypass; it is a policy-integrity / fail-open-direction defect plus a missing-validation gap.
  • The redaction primitives are a separate surface; this is the policy evaluator/loader, not the redactor.

Distinctness

Same "a security rule silently does not apply" class as my open approval_policy wildcard-block finding, but a different file, control, and mechanism: there it was the validator accepting a "*" the evaluator ignores; here it is the evaluator returning False on any unrecognized condition (fail-open specifically for denied) with no validator at all. Distinct from the WONTFIX "v0.74 egress guard fails open on its error path" (that was an exception path in the guard; this is the documented no-match behavior of the policy evaluator, no exception involved) and from the redaction-shape findings.

Fix

  • Direction-aware failure: when a condition cannot be evaluated (unknown operator / malformed shape), a rule whose then is denied/would_deny must fail closed (treat as matched, or refuse the egress), not fall through to allow. allow/approve rules may keep failing to the default.
  • Add a policy validator that rejects unknown condition operators and malformed conditions at author/load time (as approval_policy should for its wildcard block), so a silently-inert deny rule can never be persisted.
  • A bundle file that fails to parse should fail closed (or hard-error), not be silently skipped, when it is expected to carry deny rules.

Reviewed adversarially against the source before posting.

0 replies

Sign in to reply.