feat(filter): add external metering filter with identity header handling - #581
feat(filter): add external metering filter with identity header handling#581noyitz wants to merge 3 commits into
Conversation
Adds an `external_metering` HTTP filter that strips tenant identity headers and client credentials from a request before it reaches the upstream provider. Tenant identity headers are trusted input produced by an authenticating layer in front of the proxy. Forwarding them upstream would leak tenant attribution to any provider that echoes or logs them, and would let a client that sets them itself impersonate a tenant. The filter therefore strips them unconditionally on every request rather than only when they parse as well-formed. The credential headers (`authorization`, `x-api-key`) are stripped alongside them, since the gateway terminates client authentication and reaches the upstream with its own provider credentials. Configuration is a single field, `identity_header_prefix`, defaulting to `x-tenant-`, so the filter can sit behind whichever authenticating layer a deployment already runs. Response hooks run in reverse declared order, so the example config declares `external_metering` before `token_count`: on the request pass it runs first and strips the headers, and on the response pass it runs last, after `token_count` has published the token metadata that later PRs in this series will consume. Part of praxis-proxy#577. Signed-off-by: Noy Itzikowitz <nitzikow@redhat.com>
The generated description row was truncated relative to what `cargo xtask sync-example-readme` produces, failing the lint gate. Signed-off-by: Noy Itzikowitz <nitzikow@redhat.com>
praxis-bot
left a comment
There was a problem hiding this comment.
Review Summary
Clean, well-structured filter with good test coverage and correct conventions. Three medium-severity items found.
| Severity | Count |
|---|---|
| Critical | 0 |
| Large | 0 |
| Medium | 3 |
Findings:
- [Medium]
strip_client_credentialsusesif let Okfor an infallibleHeaderNameparse, creating a silent-failure pattern for security-critical credential stripping. UseHeaderName::from_static("x-api-key")instead. - [Medium]
validate_configonly checks for empty prefix but does not validate that the prefix contains valid HTTP header name characters. An invalid prefix (e.g. containing spaces or control characters) would silently match no headers, leaving tenant identity headers unstripped. - [Medium] No test covers multi-value tenant headers (
x-tenant-username: alice\r\nx-tenant-username: mallory). Add a unit test verifying all values are marked for removal when a client sends duplicate tenant headers.
| fn strip_client_credentials(ctx: &mut HttpFilterContext<'_>) { | ||
| ctx.request_headers_to_remove.push(http::header::AUTHORIZATION); | ||
|
|
||
| if let Ok(name) = "x-api-key".parse::<HeaderName>() { |
There was a problem hiding this comment.
[Medium] "x-api-key".parse::<HeaderName>() is infallible for this input, but if let Ok silently swallows a hypothetical failure. In a credential-stripping function, silent failure to remove a header is a security concern. Replace with HeaderName::from_static("x-api-key") which is compile-time validated and makes the intent unambiguous:
fn strip_client_credentials(ctx: &mut HttpFilterContext<'_>) {
ctx.request_headers_to_remove.push(http::header::AUTHORIZATION);
ctx.request_headers_to_remove
.push(HeaderName::from_static("x-api-key"));
}|
|
||
| /// Validate config at construction time. | ||
| pub(super) fn validate_config(cfg: &ExternalMeteringConfig) -> Result<(), FilterError> { | ||
| if cfg.identity_header_prefix.is_empty() { |
There was a problem hiding this comment.
[Medium] This validates non-empty but not that the prefix consists of valid HTTP header name characters (RFC 7230 tchar). A misconfigured prefix like "x tenant " or "x-tenant\t" would silently match no headers, leaving tenant identity unstripped while the filter reports healthy. Add a validation that the lowercased prefix can form a valid HeaderName prefix, e.g.:
if HeaderName::from_bytes(cfg.identity_header_prefix.as_bytes()).is_err()
&& HeaderName::from_bytes(
format!("{}test", cfg.identity_header_prefix).as_bytes(),
).is_err()
{
return Err("external_metering: identity_header_prefix contains invalid header name characters".into());
}Or simply attempt HeaderName::from_bytes(format!("{prefix}x").as_bytes()) and reject on error, since the prefix must be combinable with a suffix to form a valid header name.
| let mut req = make_request(http::Method::POST, "/v1/chat/completions"); | ||
| req.headers.insert("x-myco-username", "bob".parse().unwrap()); | ||
| let mut ctx = make_filter_context(&req); | ||
|
|
There was a problem hiding this comment.
[Medium] Missing test for multi-value tenant headers. A client could send x-tenant-username: alice and x-tenant-username: mallory on the same request. Add a test that inserts duplicate values for the same tenant header key and asserts the key appears in request_headers_to_remove (confirming all values are targeted for removal).
praxis-bot
left a comment
There was a problem hiding this comment.
Review Summary
Clean, well-structured filter with good test coverage and correct conventions. The prior review covered three medium items; this pass adds one more.
| Severity | Count |
|---|---|
| Critical | 0 |
| Large | 0 |
| Medium | 1 |
Findings:
- [Medium]
strip_identity_headersperforms two unnecessary per-request allocations: loweringHeaderName::as_str()(already lowercase byhttpcrate contract) and lowering the immutable prefix on every call instead of once at construction.
| let prefix_lower = prefix.to_ascii_lowercase(); | ||
|
|
||
| for key in ctx.request.headers.keys() { | ||
| if key.as_str().to_ascii_lowercase().starts_with(prefix_lower.as_str()) { |
There was a problem hiding this comment.
[Medium] HeaderName::as_str() already returns lowercase (the http crate normalizes header names on construction), so to_ascii_lowercase() on the key is a redundant String allocation per header per request. Additionally, the prefix is immutable after construction, so its lowercase form should be computed once in build() rather than on every request.
Pre-lower the prefix at construction:
Ok(Self {
identity_header_prefix: cfg.identity_header_prefix.to_ascii_lowercase(),
})Then simplify the hot-path function to zero allocations:
fn strip_identity_headers(ctx: &mut HttpFilterContext<'_>, prefix: &str) {
for key in ctx.request.headers.keys() {
if key.as_str().starts_with(prefix) {
ctx.request_headers_to_remove.push(key.clone());
}
}
}The header_matching_ignores_case test continues to pass because the http crate already lowercases the header name at insertion and the prefix is now pre-lowered at init.
praxis-bot
left a comment
There was a problem hiding this comment.
No new findings beyond prior review.
praxis-bot
left a comment
There was a problem hiding this comment.
No new findings beyond prior reviews.
Fourth pass confirms the four existing findings (infallible HeaderName parse, prefix validation gap, missing multi-value header test, redundant to_ascii_lowercase) are the substantive items. Code structure, conventions, test coverage, integration tests, documentation, and registry wiring are all correct.
praxis-bot
left a comment
There was a problem hiding this comment.
Review Summary
Fifth pass. One new medium finding (assertion messages in unit tests). Four items from prior reviews remain unaddressed in the code.
| Severity | Count (new) | Count (prior, open) |
|---|---|---|
| Critical | 0 | 0 |
| Large | 0 | 0 |
| Medium | 1 | 4 |
New finding:
- [Medium] Unit test assertions missing messages (
tests.rs). All 15assert!/assert_eq!calls lack a message string. The workspace enforcesmissing_assert_message = "deny"(Cargo.toml line 223), and the test module's#[allow]only coversunwrap_usedandexpect_used. The integration tests correctly include messages on every assertion.
Open prior findings (no code changes since last review):
HeaderName::from_static("x-api-key")instead of fallible.parse()withif let Ok(mod.rs:102)- Prefix validation for valid HTTP header characters (config.rs:30)
- Missing multi-value tenant header test (tests.rs)
- Redundant per-request
to_ascii_lowercaseon both key and prefix (mod.rs:88)
| let filter = filter_from_yaml("{}"); | ||
|
|
||
| assert_eq!(filter.name(), "external_metering"); | ||
| assert_eq!(filter.identity_header_prefix, "x-tenant-"); |
There was a problem hiding this comment.
[Medium] All 15 assert!/assert_eq! calls in the unit tests omit assertion messages. The workspace enforces missing_assert_message = "deny" (Cargo.toml line 223), and the test module's #[allow] only covers unwrap_used and expect_used — it does not suppress missing_assert_message.
The integration tests correctly include messages on every assertion. The unit tests should follow the same practice for consistent diagnostics on failure.
Example fix for this line:
assert_eq!(filter.name(), "external_metering", "filter name should be external_metering");
assert_eq!(filter.identity_header_prefix, "x-tenant-", "default prefix should be x-tenant-");
Adds an
external_meteringHTTP filter that strips tenant identity headers andclient credentials from a request before it reaches the upstream provider.
Part of #577.
Why strip unconditionally
Tenant identity headers are trusted input produced by an authenticating layer in
front of the proxy. They must never reach the upstream, for two reasons:
The filter therefore strips them on every request rather than only when they
parse as well-formed.
authorizationandx-api-keyare stripped alongsidethem, since the gateway terminates client authentication and reaches the
upstream with its own provider credentials.
Configuration
Expected headers under that prefix are
{prefix}username,{prefix}group,{prefix}subscription, and{prefix}model. The prefix is configurable so thefilter can sit behind whichever authenticating layer a deployment already runs.
Ordering in the pipeline
Response hooks run in reverse declared order, so the example config declares
external_meteringbeforetoken_count. On the request pass it runs first andstrips the identity headers; on the response pass it runs last, after
token_counthas published the token metadata that later PRs consume.Why this is the first of a series
The full metering capability is roughly 1,900 lines. The
pr-conventionsjobcaps a PR at 750 counted additions, and a change that size is not reviewable in
one pass regardless of the gate. It is split into four independently useful,
independently testable PRs:
token_countPR 2 — admission control. Captures the tenant identity into filter state and
calls an external metering service before the request is proxied. Adds
metering_url,timeout_seconds,feature_key, andfail_open. A tenant withno remaining entitlement is rejected with 429; an unreachable metering service
follows
fail_open, defaulting to true so a metering outage degrades tounmetered service rather than an inference outage.
PR 3 — usage reporting. Emits a CloudEvents 1.0
inference.tokens.usedeventafter the response completes, and
inference.request.errorfor failed requests.Reads the counts
token_counthas already written to filter metadata, so thereis no second extraction path to keep in sync. Fire-and-forget; never blocks the
response.
PR 4 — prompt cache accounting. Disjoint file set, so it can land in any
order relative to PRs 2 and 3.
PRs 2 and 3 build on this one and are sequential.
Tests
filters/src/metering/tests.rscovering config defaults,empty-prefix rejection, unknown-field rejection, prefix matching including
case-insensitivity, and credential stripping.
tests/integration/tests/suite/examples/external_metering.rsasserting against the example config that identity and credential headers do
not reach the upstream and that unrelated headers pass through untouched.
examples/configs/external-metering.yamlwith the full pipeline.Verification
make build,make test, andmake lintall pass on this branch(3,134 tests, 0 failures).