Skip to content

feat(callout): add http_callout filter (Lakera Guard) — supersedes #727 - #760

Open
usize wants to merge 23 commits into
praxis-proxy:mainfrom
usize:feat/http-callout-lakera
Open

feat(callout): add http_callout filter (Lakera Guard) — supersedes #727#760
usize wants to merge 23 commits into
praxis-proxy:mainfrom
usize:feat/http-callout-lakera

Conversation

@usize

@usize usize commented Aug 17, 2026

Copy link
Copy Markdown
Member

Summary

Adds a generic http_callout HTTP filter: makes an outbound call during request
processing via core's SubRequestConnector, optionally reshapes the forwarded body
with JSONPath field mapping, extracts values from the JSON response into the
FilterResultSet for branch_chains evaluation, and can inject callout response
headers 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 integration
tests) screens requests through Lakera Guard, which returns an exact boolean —
this works with today's exact-equality on_result matching and is the guardrails
integration 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 the
richer on_result matching proposed in praxis-proxy/praxis#964. This PR is NOT
blocked by praxis#964.

Testing

  • 73 unit tests: config validation (incl. nested deny_unknown_fields), SSRF
    checks, env-var expansion, JSONPath extraction coercion/sanitization, body
    shaping (incl. non-JSON raw-forward fallback), failure modes, circuit breaker,
    depth limit, phase handling, and header forwarding/injection (incl. absence).
  • Functional integration tests for the example config: flagged → 403, clean → 200,
    and GET bypasses the POST-scoped callout.
  • make lint, make test, make doc, make audit green locally.

Refs: #727, #484, #758; praxis-proxy/praxis#964

@usize
usize requested review from a team and franciscojavierarceo August 17, 2026 21:07
@usize usize mentioned this pull request Aug 17, 2026
@jordigilh

Copy link
Copy Markdown

Nice test coverage on this one (74 filter-level cases + wiremock integration). One security gap worth a look before merge, plus two minor notes.

SSRF / DNS-rebinding: validate_callout_url only checks the URL's literal host for private/loopback IPs — it warns, doesn't reject, and there's a test (validate_url_warns_on_private_ip) confirming that's deliberate. But resolve_peer() does a fresh DNS lookup per request with no check on the resolved address. A domain-based target (the normal case) that resolves to 169.254.169.254 or an internal service at request time bypasses the check entirely — untested. Given callout targets are usually domains, this is the more realistic SSRF vector than the literal-IP case that's currently guarded.

Minor, non-blocking:

  • max_body_bytes also bounds the response read from the callout (via SubRequestClient::execute), but build_subrequest_client uses SubRequestClient::new() rather than with_max_response_bytes, so it silently clamps to the 64 MiB client-wide ceiling regardless of a higher configured value — worth a doc note or a with_max_response_bytes call so config matches behavior.
  • inject_headers values from the callout response skip the same sanitization (sanitize_string) that JSONPath-extracted body values get — same untrusted origin, two trust levels.

@praxis-bot praxis-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread filters/src/callout/config.rs
Comment thread filters/src/callout/mod.rs
Comment thread filters/src/callout/mod.rs
Comment thread filters/src/callout/extract.rs Outdated
Comment thread filters/src/callout/mod.rs
Comment thread filters/src/callout/mod.rs
gdozortsev and others added 13 commits August 18, 2026 09:50
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>
@praxis-bot-app

Copy link
Copy Markdown

Missing Signed-off-by: 4d127b8, bab449a, 4dd5c33, c65a5ff, e30ccfc, a1baf78, 884164c, 3f8d158. All commits require sign-off (via git commit --signoff).

usize added 8 commits August 18, 2026 14:53
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>
@usize
usize force-pushed the feat/http-callout-lakera branch from 3f8d158 to 43583fa Compare August 18, 2026 21:54
@usize
usize enabled auto-merge August 18, 2026 21:56
@usize
usize disabled auto-merge August 18, 2026 21:56

@praxis-bot praxis-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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_bytes field (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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants