feat(filter): outbound chain binding with authority-bound deferred credentials - #1126
Conversation
|
PR too large: 5334 lines added (limit: 500, excludes Cargo files, tests, docs, examples, and benchmarks). Please split into smaller PRs. |
praxis-bot
left a comment
There was a problem hiding this comment.
PR Review: Outbound chain binding with authority-bound deferred credentials
Adds a chain-binding mechanism that lets application filters (e.g. AI callouts) own a prebuilt outbound FilterPipeline resolved at construction time, plus an authority-bound deferred credential system that materializes secrets only after destination resolution. Bound chains face the same validation gates as top-level chains (cardinality, SSRF, branch limits, terminal/TCP filter rejection, ordering). Budgets are shared across binding boundaries so the ceiling cannot be evaded.
Assessment
This is a well-structured, security-conscious PR with thorough build-time validation and extensive test coverage across all modules. The credential binding model is sound -- secrets are sealed until the resolved authority matches, and injection happens after sanitization with Host pinning to prevent vhost-targeted exfiltration. The build-time budget sharing (materialization instances and branch definitions) correctly prevents fan-out evasion across binding boundaries. Two medium-severity test coverage gaps are noted below.
Findings
| Severity | File | Finding |
|---|---|---|
| Medium | credentials.rs |
IPv6 authority parsing paths lack test coverage |
| Medium | credentials.rs |
inject_canonical silently swallows invariant violation |
7c9257f to
5533b01
Compare
|
There was a problem hiding this comment.
A response-ceiling breach in CalloutStreamingBody::checked doesn't end the stream, so polling again resumes around the rejected chunk and hands the caller a body with a hole in it instead of an error — fix and failing-test evidence in the previous comment.
…edentials Implement issue praxis-proxy#1074: a filter can bind a nested "outbound" filter chain that runs against the resolved upstream, injecting credentials that are staged during request processing and materialized only once the upstream authority is known. Chain binding: ChainBindingContext resolves a ChainRef (named or inline) into a FilterPipeline at pipeline-build time, bounded by a maximum nesting depth. Bound chains are held to the same validation top-level chains face, which they would otherwise bypass by never appearing in Config::filter_chains: the per-chain filter-count cap via validate_chain_entries_cardinality, empty-predicate condition rejection — top-level and inside inline branch sub-chains — via validate_chain_entries_conditions, inline-cluster SSRF and insecure-TLS gating via validate_chain_entries_inline_clusters, and the core branch-chain constraints (the re-entrant max_iterations ceiling, nesting depth, per- filter and total branch caps, filter-name uniqueness, chain-reference resolution) via validate_chain_entries_branch_chains. The total-branch cap is a configuration-wide budget: it is seeded with the whole configuration's branch count (the listener's own branches plus every named chain) and accumulated across every bound inline chain in the build, so bindings cannot each stay under the ceiling while exceeding it collectively — nor start counting from zero and ignore branches already present elsewhere in the configuration. A named outbound chain is a top-level filter chain the whole-config validate_branch_chains pass already counted once toward the ceiling, so it is validated when bound but not re-accumulated; only inline outbound chains, invisible to that pass, accumulate. Seeding config-wide (not listener-scoped) keeps a named chain the listener never references — which still materializes when bound — in the baseline that inline bindings add on top of, so the named pool and the inline pool cannot each sit under the ceiling while exceeding it together. A bound chain that contains a terminal filter or a TCP-level filter — at the top level or nested inside a branch sub-chain — is rejected at build time because the HTTP filtered sub-request executor forwards to a resolved upstream and can run neither. Terminal filters are detected by the HttpFilter::produces_terminal_response capability rather than a hard-coded builtin name list, so a custom filter that returns a terminal action cannot escape the check. build_with_chains now threads InsecureOptions so a bound chain cannot bypass those gates. Branch-recursion completeness: the pipeline-wide scans that enforce these contracts all traverse branch sub-chains rather than only top-level entries, so a filter buried in a branch is treated exactly like a top-level one instead of silently escaping validation or lifecycle wiring. This covers non-HTTP and terminal-filter detection, the failure_mode: open security guardrail (check_open_security_filters, which errors or — under insecure_options.allow_open_security_filters — warns, consistent with the SkipTo and Terminal branch-bypass checks), referenced-file discovery for reload, body-limit and insecure-option propagation, and session-store injection. Deferred credentials: PendingCredentials/DeferredCredential stage secrets keyed to a canonical logical authority. The subrequest executor materializes them only into a request bound for the authority each credential was issued for, and only after header sanitization so the credential header is never stripped as hop-by-hop or framing. A deferred credential may not carry Host, content-length, or a hop-by-hop header, so an injected credential cannot retarget the request after its authority was authorized. Logical authority vs transport: credentials bind to the operator-configured authority override (the logical HTTP authority), not the transport endpoint bytes travel to. The outbound subrequest also sends that authority as its Host header, mirroring the normal proxy path's authority override and honoring the documented Upstream.authority contract; the transport endpoint is used as the Host only when no override is configured. The sent Host is pinned to the authorized authority only when a credential is actually injected, keeping a secret from reaching a shared-vhost endpoint under a different Host than it was scoped to; a staged credential that matches nothing injects no secret and leaves a caller- or step-set Host untouched, so it cannot silently change virtual-host routing. Reload contract: bound outbound chains are rebuilt and re-injected with runtime resources (KV stores, session stores, subrequest client) on config reload. Resource injection recurses into nested pipelines — both filter- embedded outbound chains and branch sub-chains — so bound chains observe the parent pipeline's stores. Signed-off-by: Sébastien Han <seb@redhat.com>
…auth Address the two review findings on the authority-bound deferred credential system: - inject_canonical no longer silently swallows a re-validation failure. When a pre-validated credential value fails HeaderValue::from_str at injection time, warn! and drop it (still a no-op, no header written). The diagnostic omits the secret value and the offending bytes. - Add IPv6 authority coverage: bracketed and unbracketed literals for both exact authority bindings and host wildcards, plus the port-required rejection paths. - Add a capture_logs test helper and a regression test proving the invariant-violation path warns, writes no header, and never leaks the secret. Signed-off-by: Sébastien Han <seb@redhat.com>
|
Once the fix is considered this PR can be approved. |
5533b01 to
d0b470f
Compare
CalloutStreamingBody::checked returned an error on a ceiling breach (or emitted-byte counter overflow) but left the stream runnable: finished stayed false and pending/inner were untouched. A caller that polled again after the rejected chunk resumed around it, receiving a body with a hole instead of an error-terminated stream. Split checked into a teardown wrapper that, on rejection, sets finished = true and clears pending so a re-poll cannot resume, plus an account helper holding the original byte-accounting logic. inner is left in place deliberately so async cancel/suppress and extension recovery still work. Reported by maintainer @twghu. Signed-off-by: Sébastien Han <seb@redhat.com>
Addressed in 5013eb9 - PTAL :) |
…binding PendingCredentials and DeferredCredential are the credential half of the outbound-callout mechanism (#1126), so they now compile only under the experimental chain-binding feature alongside register_chain_binding. The filtered-subrequest credential drain is gated with them; default builds drop the unused authority-bound-credential surface. Signed-off-by: Shane Utt <shaneutt@linux.com>
What does this PR do?
Implements issue #1074: lets a filter bind a nested "outbound" filter chain (named or inline) that runs against the resolved upstream, exposing a small public
FilteredSubrequestExecutor::run()API that returns either a buffered or a streaming response. Bound chains are held to the same validation top-level chains face — filter-count cap, empty-predicate rejection, inline-cluster SSRF/insecure-TLS gating, branch-chain constraints (max-iterations ceiling, nesting depth, per-filter and configuration-wide total-branch caps), and rejection of terminal/TCP filters — all of which they would otherwise bypass by never appearing inConfig::filter_chains, with every scan traversing branch sub-chains. Credentials are staged during request processing and materialized only into a request bound for the canonical logical authority they were issued for, after header sanitization, and only when actually injected, so a secret cannot reach a shared-vhost endpoint under a differentHost. Bound outbound chains participate fully in the config-reload contract, being rebuilt and re-injected with runtime resources (KV stores, session stores, subrequest client) including into nested and branch-embedded pipelines. No IRR or AI-client migration is included.Which issue(s) does this relate to?
Fixes #1074
Checklist
git commit -s)make lint && make test && make test-integrationpasses locallyDoes this introduce a breaking change?
No.