Feat/http callout - #727
Closed
gdozortsev wants to merge 2 commits into
Closed
Conversation
Member
|
Superseded by #760. I'm taking this PR over from @gdozortsev who was pulled into another project. Thank you for your work here Gaby. Your commits are on the new branch. Look forward to seeing you here again in the future. |
shaneutt
pushed a commit
that referenced
this pull request
Aug 24, 2026
* feat: http_callout using SubRequestConnector
Signed-off-by: Gabriela Dozortsev <gdozorts@redhat.com>
Signed-off-by: usize <mofoster@redhat.com>
* control character parsing for llama guard
Signed-off-by: Gabriela Dozortsev <gdozorts@redhat.com>
Signed-off-by: usize <mofoster@redhat.com>
* chore: regenerate Cargo.lock after rebase onto main
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>
* feat(callout): scope examples to Lakera Guard
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>
* fix(callout): resolve clippy lints and rustfmt drift
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>
* docs(callout): make filter visible to docs generator and add reference 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 #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>
* fix(callout): inject response headers with set semantics
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>
* fix(callout): validate result_key at config time; skip rejected values
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>
* fix(callout): remove dead failure_mode alias on on_failure
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>
* test(callout): port coverage gaps from earlier review rounds
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>
* docs(callout): regenerate http_callout doc for on_failure description
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>
* fix(callout): validate status_on_error is a legal HTTP status
praxis-bot review (#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>
* docs(callout): note status validation duplication for follow-up
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>
* test(callout): cover non-2xx callout response forwarding
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>
* test(callout): cover userinfo-in-URL rejection
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>
* fix(callout): stop stripping slashes from extracted values
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>
* test(callout): cover https target parsing (TLS/SNI/port)
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>
* feat(callout): warn on disallowed forward_header at config time
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>
* fix(callout): cap response body at configured max_body_bytes
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>
* style(callout): apply nightly rustfmt to fixup commits
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>
* fix(callout): block resolved private/loopback peers (SSRF/rebinding)
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
#771 for unifying the existing copies. Harden the
lakera-guard example accordingly.
Assisted by Opus 4.8
Signed-off-by: usize <mofoster@redhat.com>
* fix(agentic): gate parse_json_rpc_body body arg for praxis-core main
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>
* docs(callout): note max_body_bytes also caps callout response body
request.max_body_bytes is passed both to the forwarded-request buffer and
to SubRequestClient::with_max_response_bytes (build_subrequest_client) and
the per-request execute limit, so it also bounds how large a callout
response the filter will accept. The field doc only described the request
role; note the dual role and regenerate the reference doc.
Addresses praxis-bot review comment on filters/src/callout/config.rs.
Assisted by Opus 4.8
Signed-off-by: usize <mofoster@redhat.com>
* fix(callout): box-pin large callout futures to satisfy clippy
The praxis-proxy-filter 0.5.3 API changes (merged from main) enlarged the
SubRequestClient::execute future, pushing the callout await chain over
clippy's large_futures / large_stack_frames thresholds under -D warnings.
Box::pin the awaited futures at each flagged site (client.execute,
perform_callout, and the execute_callout calls in on_request /
on_request_body), matching the fix main applied to apis/src/subrequest.rs
in 5b45cc1. Moves the future to the heap so the stack frame stays small.
Assisted by Opus 4.8
Signed-off-by: usize <mofoster@redhat.com>
* test(server): opt dump loopback fixtures into allow_private_endpoints
praxis-core main added validation of clusters defined inline in
load_balancer filters (praxis 894ac183), so a loopback cluster endpoint
like 127.0.0.1:9090 is now rejected at config-parse time unless
insecure_options.allow_private_endpoints is set. This broke the two
credential_injection dump tests under the test-praxis-main canary
(server/src/dump.rs config-parse panic), while the released 0.5.3 build
stays green. The failing tests are unrelated to their subject (credential
redaction); they panic earlier during Config::from_yaml.
Add the same insecure_options.allow_private_endpoints: true opt-in that
praxis-core applied to its own loopback fixtures (praxis 185545ae). The
field exists in released 0.5.3, so the default build is unaffected.
Verified: both tests pass against released 0.5.3 and against core main
(--features praxis-main); full server bin, filters, and apis suites pass
against core main.
Assisted by Opus 4.8
Signed-off-by: usize <mofoster@redhat.com>
* test(callout): opt lakera-guard example into allow_private_endpoints
praxis-core main validates clusters defined inline in load_balancer
filters and rejects private/loopback/link-local endpoints unless the
config sets `insecure_options.allow_private_endpoints: true` (core commit
894ac18). #800 added this opt-in to every shipped example, but the
lakera-guard example was introduced on this branch and so was missed,
leaving `test-praxis-main` red on the schema parse_configs check:
examples/configs/lakera-guard.yaml: chain 'routing':
filter 'load_balancer': cluster 'backend': endpoint '127.0.0.1:3000'
resolves to a sensitive address; set
insecure_options.allow_private_endpoints: true to allow
Mirror #800's remediation: append the top-level insecure_options block.
The flag ships in released praxis 0.5.3, so this is green on both the
pinned dependency and core main. The lakera integration test builds its
config manually (not via the allow_loopback_endpoints helper), so it
inherits the flag from the file with no duplicate-key collision.
Assisted by Opus 4.8
Signed-off-by: usize <mofoster@redhat.com>
* fix(callout): trim trailing whitespace and correct the sanitize docs
The `sanitize_string` docstring claimed it "drops `/` and `\` from the
retained text". It does not, and has not since slash stripping was removed
— `split_at_first_control` splits only on control characters, and its own
doc says slashes are preserved verbatim. The two comments contradicted each
other, which is what prompted the review question.
Fixing the docstring surfaced a real gap next to it: leading whitespace was
trimmed but trailing whitespace was not, so a provider returning `"safe "`
produced `"safe "` and silently failed `on_result` exact-equality matching
against a config saying `safe`. Trim both ends.
The trim runs after the control-character split, so the `warn!` still
reports the untrimmed dropped remainder and truncation still applies to the
final value.
Tests added:
- trailing/surrounding whitespace is trimmed, interior spacing is not
- whitespace sitting just before a control character is removed
- whitespace-only input yields `None`
- truncation at, over, and exactly at `MAX_SANITIZED_LEN`
- the UTF-8 boundary walk, including 2-, 3-, and 4-byte characters, so a
multi-byte character straddling byte 255 is never split
- `coerce_value` over null, bool, number, string, array, and object
Verified the four behavioral tests fail against the pre-fix implementation
and pass after it.
Assisted by Opus 5
Signed-off-by: usize <mofoster@redhat.com>
* feat(callout)!: gate http_callout behind an experimental build flag
Per review: the filter is a work in progress, so put a build flag around it
and let it soak before it counts as supported surface.
Adds `http-callout-filter` to praxis-ai-filters and praxis-ai-proxy, off by
default. It activates an `experimental` marker feature, mirroring how praxis
core's server crate buckets `basic-auth-filter` under `experimental`, so
consumers can gate on "anything experimental" without naming each feature.
Gated: the `callout` module and its `HttpCalloutFilter` re-export, the
registration in `register_ai_filters`, and the `lakera-guard` example
integration test (it runs the proxy in-process, so the filter must be
compiled in). The registry test now asserts both directions — present with
the feature, absent without it — so the gate cannot silently regress.
Documented in the filter's struct doc, which flows into the generated
`docs/filters/http_callout.md`, and in the example config's usage line.
Verified both configurations: default build 1073 filter tests pass with no
callout code compiled in; with the feature, 1172 pass plus the three lakera
integration tests. Clippy clean both ways; full workspace suite green.
BREAKING CHANGE: `http_callout` is no longer registered in a default build.
Enable `--features http-callout-filter` to use it.
Assisted by Opus 5
Signed-off-by: usize <mofoster@redhat.com>
---------
Signed-off-by: Gabriela Dozortsev <gdozorts@redhat.com>
Signed-off-by: usize <mofoster@redhat.com>
Co-authored-by: Gabriela Dozortsev <gdozorts@redhat.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Continuation of #484, refactored to use SubRequestConnector for routing. This allows us to make arbitrary API calls, passing selected fields from payloads and to parse any results. It's particularly useful for integrating with third party guardrails services.
This is currently blocked by: praxis-proxy/praxis#964