feat(callout): add http_callout filter (Lakera Guard) — supersedes #727 - #760
feat(callout): add http_callout filter (Lakera Guard) — supersedes #727#760usize wants to merge 23 commits into
Conversation
|
Nice test coverage on this one (74 filter-level cases + SSRF / DNS-rebinding: Minor, non-blocking:
|
praxis-bot
left a comment
There was a problem hiding this comment.
PR Review
Adds a generic http_callout filter with JSONPath extraction, body shaping, fail-open/closed, circuit breaking, and depth-based loop prevention. Ships a Lakera Guard example config with functional integration tests.
Overall Assessment
Well-structured implementation with good separation of concerns across config, extraction, and execution. Test coverage is thorough (73 unit tests + 3 integration tests) but has gaps in a few important code paths. One validation gap (status_on_error) and one untested runtime path (non-2xx callout responses) should be addressed.
Findings Summary
| Severity | Count |
|---|---|
| Large | 2 |
| Medium | 4 |
All findings are placed as inline comments.
Signed-off-by: Gabriela Dozortsev <gdozorts@redhat.com> Signed-off-by: usize <mofoster@redhat.com>
Signed-off-by: Gabriela Dozortsev <gdozorts@redhat.com> Signed-off-by: usize <mofoster@redhat.com>
The lockfile on the PR branch carried dangling references to thiserror 2.0.19 (no matching package entry), which panicked cargo-audit in the security-audit and dependency-check jobs. Reset to main and re-resolve so only the serde_json_path additions remain. Assisted by Opus 4.8 Signed-off-by: usize <mofoster@redhat.com>
Defer the LlamaGuard example to a follow-up: its verdict format
("unsafe\nS02", "unsafe01".."unsafe13") needs the richer on_result
matching proposed in praxis-proxy/praxis#964. Lakera Guard returns
exact "true"/"false", which works with exact-equality matching
today, and is the guardrails integration required by the AI
Gateway MVP (ai#758, success criterion 4).
Move lakera-guard.yaml back to examples/configs/ (the subdirectory
only existed to group the two guard examples) and regenerate the
examples README table.
Assisted by Opus 4.8
Signed-off-by: usize <mofoster@redhat.com>
Refactor the callout filter to satisfy the workspace lint gates (clippy -D warnings, nightly rustfmt): - introduce a CalloutTarget struct in place of the six target_* fields and the parse_callout_target six-tuple, splitting scheme and host validation into helpers; - extract callout header assembly, response handling, the network round-trip, depth parsing, and the max_body_bytes bound check into focused helpers to clear too_many_lines/cognitive_complexity and large_stack_frames on execute_callout; - document and split sanitize_string, replace string indexing with checked slicing, and neutralise its LlamaGuard-specific warning; - hoist DISALLOWED_FORWARD_HEADERS to a module const; - assert http_callout registration in the AI registry test. Assisted by Opus 4.8 Signed-off-by: usize <mofoster@redhat.com>
…e docs cargo xtask generate-filter-docs discovers filter anchors by the string literal returned from name(); returning the FILTER_NAME const made http_callout invisible, so docs/filters/http_callout.md was never generated (AGENTS.md test requirement praxis-proxy#5). Return the "http_callout" literal directly, keep FILTER_NAME for internal use with a drift test, lead the struct doc with a descriptive summary line, and check in the generated reference docs. Assisted by Opus 4.8 Signed-off-by: usize <mofoster@redhat.com>
Inject callout response headers via ctx.request_headers_to_set (overwrite) instead of ctx.extra_request_headers (append), so a header taken from the trusted callout response replaces any client-supplied header of the same name rather than being appended alongside it. Also drops a lossy HeaderValue::to_str() conversion by pushing the HeaderValue directly. Adds a test that an inject header absent from the response is not injected. Assisted by Opus 4.8 Signed-off-by: usize <mofoster@redhat.com>
P3: CompiledExtraction::compile now probes result_key against the FilterResultSet key rules (ASCII alphanumeric/_/- , 1-64 bytes) with an empty value, so invalid keys such as "lakera.flagged" or an empty key fail at startup rather than silently on every request. P4: evaluate no longer returns Result. A coerced value rejected by the result-set limits is logged (warn) and skipped instead of propagating through handle_success and failing the whole request via ?, so an oversized/hostile third-party response value is handled per the on_failure policy. handle_success/handle_response drop their now unnecessary Result wrappers. Note: with the key validated at config time and sanitize_string capping string/array/object coercions at 255 bytes (below the 256 value limit), the value-rejection branch is defense-in-depth and not reachable through the current coercion pipeline; it degrades gracefully if those limits ever change. Assisted by Opus 4.8 Signed-off-by: usize <mofoster@redhat.com>
The #[serde(alias = "failure_mode")] on on_failure could never bind: core strips failure_mode as a structural pipeline key (see praxis-proxy-filter factory strip_structural_keys) before the filter config is parsed, and it controls a different semantic (how the pipeline reacts to a filter *error*, not how this filter reacts to a *callout* failure). Remove the misleading alias and document the distinction. Assisted by Opus 4.8 Signed-off-by: usize <mofoster@redhat.com>
Add the test cases that earlier review rounds surfaced but that had not
made it into the scoped branch:
- forward_header_absent_from_request_not_sent: a configured
forward_header that is absent from the downstream request is not sent
to the callout (asserts against the mock's received requests).
- body_shaping_non_json_forwards_raw: when body shaping is configured
but the downstream body is not JSON, the raw body is forwarded
verbatim rather than dropped, and extraction still succeeds.
- config_rejects_unknown_{target,response,circuit_breaker}_field:
deny_unknown_fields is enforced on the nested config structs, not
just the top level.
- lakera_guard_get_bypasses_callout (integration): the example scopes
the callout to methods: [POST], so a GET reaches the upstream without
a callout even when Lakera would flag it.
Assisted by Opus 4.8
Signed-off-by: usize <mofoster@redhat.com>
The generated field table lagged the source doc comment on `on_failure` after the dead `failure_mode` alias was removed. Regenerate so `docs/filters/http_callout.md` matches the config source and passes `cargo xtask lint-filter-docs`. Assisted by Opus 4.8 Signed-off-by: usize <mofoster@redhat.com>
praxis-bot review (praxis-proxy#760): status_on_error accepted any u16, so values like 0, 99, or 65535 would produce a nonsensical HTTP status on the rejection path. Validate it falls in 100-599 at config time, alongside the existing max_body_bytes check; an unset value still defaults to 403. Assisted by Opus 4.8 Signed-off-by: usize <mofoster@redhat.com>
The 100..=599 status check is duplicated across openai_responses_compact, web_search, and now callout. Record the known duplication and the plan to promote a shared helper into praxis-ai-apis so the follow-up dedupe is discoverable from the code. No behavior change. Assisted by Opus 4.8 Signed-off-by: usize <mofoster@redhat.com>
142f9f1 to
3f8d158
Compare
A completed callout that answers with a non-2xx status forwards that status to the downstream client, which is distinct from a transport failure applying status_on_error. The new test mounts a mock returning 500 with on_failure: open and asserts Reject(500) — proving the non-2xx branch forwards the callout's own status regardless of failure mode. Assisted by Opus 4.8 Signed-off-by: usize <mofoster@redhat.com>
parse_host rejects URLs containing '@' to prevent embedded credentials from leaking into logs or being forwarded to the callout target. Add a test asserting both user:pass@host and bare user@host are rejected at config time with an error that mentions userinfo. Assisted by Opus 4.8 Signed-off-by: usize <mofoster@redhat.com>
split_at_first_control removed '/' and '\\' from every extracted
value, mangling legitimate results ("unsafe/S02" -> "unsafeS02",
"safe/clean" -> "safeclean") and silently breaking on_result
matching. The stripping was incidental to the original control-character
work for llama guard and served no security purpose: control-character
truncation already defends against CR/LF/header-injection, and slashes
are ordinary value characters.
Preserve non-control characters verbatim and split at the first control
character only. Add tests pinning slash preservation and confirming a
control character still truncates.
Assisted by Opus 4.8
Signed-off-by: usize <mofoster@redhat.com>
CalloutTarget::parse had no coverage of the https path. Add tests asserting https enables TLS, sets SNI to the host, defaults the port to 443 and omits it from the Host authority; that a non-default https port is kept in the authority while SNI stays host-only; and that http disables TLS, defaults to port 80, and leaves SNI empty. Assisted by Opus 4.8 Signed-off-by: usize <mofoster@redhat.com>
Hop-by-hop and sensitive headers in DISALLOWED_FORWARD_HEADERS are silently skipped at request time, so an operator who lists one as a forward_header gets no feedback that it is a no-op. Emit a warning per such header at config time; the entry remains non-fatal and the request-time skip is unchanged as defense-in-depth. Add a test that a disallowed forward_header is accepted (warns, not errors) and a wiremock test proving a disallowed header is not sent to the callout while an allowed one is. Assisted by Opus 4.8 Signed-off-by: usize <mofoster@redhat.com>
build_subrequest_client used SubRequestClient::new, leaving the client response-byte ceiling at its 64 MiB default. Because execute() uses min(per_call_limit, client_ceiling), a configured max_body_bytes above 64 MiB was silently clamped to 64 MiB. Construct the client with with_max_response_bytes(connector, max_body_bytes) so the effective response limit always equals the operator's configured value. Add a test that a response body larger than max_body_bytes fails the callout (Reject(status_on_error) under on_failure: closed). Assisted by Opus 4.8 Signed-off-by: usize <mofoster@redhat.com>
Wrap two over-width lines flagged by nightly rustfmt in the status_on_error validator error and the https target-parse assertion. No behavior change. Assisted by Opus 4.8 Signed-off-by: usize <mofoster@redhat.com>
Add opt-in `target.allow_private_addresses` (default true, preserving current warn-only behavior). When false, resolve_peer rejects a resolved private/loopback/link-local peer after DNS resolution, closing the DNS-rebinding gap that the config-time literal-IP check cannot catch (e.g. a hostname resolving to 169.254.169.254). A blocked peer is treated as a callout failure and follows on_failure. Defer to the shared classifier praxis_core::connectivity::is_private_ip rather than adding another hand-rolled private-address predicate; see praxis-proxy#771 for unifying the existing copies. Harden the lakera-guard example accordingly. Assisted by Opus 4.8 Signed-off-by: usize <mofoster@redhat.com>
3f8d158 to
43583fa
Compare
praxis-bot
left a comment
There was a problem hiding this comment.
Re-review (Aug 19 commits)
All six findings from the initial review have been addressed. The fixup commits also add two new features (response-body capping, resolved-peer SSRF/DNS-rebinding protection) and both are well-tested. One new finding below.
| Severity | Count |
|---|---|
| Medium | 1 |
|
|
||
| /// Maximum request body bytes to buffer and forward. | ||
| #[serde(default = "default_max_body_bytes")] | ||
| pub max_body_bytes: usize, |
There was a problem hiding this comment.
[Medium] This field now also controls the maximum callout response body size (via SubRequestClient::with_max_response_bytes in build_subrequest_client, and the per-request limit passed to execute). The doc comment and generated reference docs say only "Maximum request body bytes to buffer and forward," so an operator has no indication that their request-body budget also caps how large a callout response the filter will accept.
Consider either:
- updating the doc comment to mention the dual role (e.g. "… and the maximum callout response body size"), or
- adding a separate
response.max_body_bytesfield (defaulting to the request value) for explicit control
praxis-core `main` changed the first parameter of `parse_json_rpc_body` from `&Option<Bytes>` (released 0.5.2) to `Option<&Bytes>`, which breaks the A2A and MCP filters under the `test-praxis-main` compatibility job (E0308) at a2a/mod.rs:178 and mcp/mod.rs:148. This is upstream drift caught by the forward-looking canary, not a defect in this branch: the default build pins praxis-core 0.5.2 and stays green, while `test-praxis-main` clones core `main` at HEAD. Gate the call argument on the existing `praxis-main` feature, matching the pattern already used in inference/model_to_header.rs: pass `&*body` against 0.5.2 and `body.as_ref()` against core `main`. A TODO marks the gate for removal once we pin to a praxis-core release that ships the new signature. Assisted by Opus 4.8 Signed-off-by: usize <mofoster@redhat.com>
Summary
Adds a generic
http_calloutHTTP filter: makes an outbound call during requestprocessing via core's
SubRequestConnector, optionally reshapes the forwarded bodywith JSONPath field mapping, extracts values from the JSON response into the
FilterResultSetforbranch_chainsevaluation, and can inject callout responseheaders into the upstream request. Supports fail-open/fail-closed, per-target timeout,
circuit breaking, and callout-depth loop prevention.
This supersedes #727 (itself a continuation of #484) and was written by
@gdozortsev — her two commits are preserved as-authored; I've rebased them onto
main, added sign-offs, and stacked conflict/lint/scope fixes and extra hardening
on top. Thanks Gaby!
Scope: Lakera Guard now, LlamaGuard later
The shipped example (
examples/configs/lakera-guard.yaml+ functional integrationtests) screens requests through Lakera Guard, which returns an exact boolean —
this works with today's exact-equality
on_resultmatching and is the guardrailsintegration required by the AI Gateway MVP (#758, success criterion 4; consumed by
the Track C quickstart).
A LlamaGuard example is deferred: its verdict format (
"unsafe\nS02") needs thericher
on_resultmatching proposed in praxis-proxy/praxis#964. This PR is NOTblocked by praxis#964.
Testing
deny_unknown_fields), SSRFchecks, env-var expansion,
JSONPathextraction coercion/sanitization, bodyshaping (incl. non-JSON raw-forward fallback), failure modes, circuit breaker,
depth limit, phase handling, and header forwarding/injection (incl. absence).
and GET bypasses the POST-scoped callout.
make lint,make test,make doc,make auditgreen locally.Refs: #727, #484, #758; praxis-proxy/praxis#964