fix: close fail-open paths found by the security analysis - #45
Conversation
A panicking parallel branch, omitted JWT audiences, != on a missing attribute, and an unreadable handler result all resolved toward Allow. Close those, enforce the egress table on the bundled transport, and stop leg-2 OAuth errors from echoing the subject token. Dispositions are in docs/security-analysis.md. Signed-off-by: mkoushni <mkoushni@redhat.com>
idp_rejection_surfaces_error_code still required the free-text description in the violation. That was the leak the analysis closed; keep the OAuth error code and require the description absent. Signed-off-by: mkoushni <mkoushni@redhat.com>
clippy::doc_markdown fails the lint gate without it. Signed-off-by: mkoushni <mkoushni@redhat.com>
Treating NaN and inf as non-numeric still skipped a max-amount deny. The phase now Denies; `!` cannot invert that into Allow. Signed-off-by: mkoushni <mkoushni@redhat.com>
Keep the security changelog entries and main's hook_names removal. Signed-off-by: mkoushni <mkoushni@redhat.com>
Bare [`skip_audience_validation`] is not in scope; rustdoc -D warnings failed the docs job. Point it at Self::skip_audience_validation. Signed-off-by: mkoushni <mkoushni@redhat.com>
praxis-bot
left a comment
There was a problem hiding this comment.
PR Review
Summary: Comprehensive security pass closing 7 fail-open paths (F1-F7). The fixes correctly implement fail-closed semantics and are well-tested. Found 2 Critical issues and 3 Large concerns.
| Severity | Count |
|---|---|
| Critical | 2 |
| Large | 3 |
Critical Issues
[Critical] crates/ppe-apl-core/src/evaluator.rs - coerce_f64_attr precision loss
The coerce_f64_attr function can silently truncate large integers (>2^53) during coercion in mixed int/float comparisons. While integer-to-integer comparisons are handled exactly (lines 262-269), a comparison like args.amount > 9007199254740993.0 (float literal) would coerce a 2^53+1 integer through f64, collapsing it to 2^53 and making the comparison incorrectly return false. This could create a fail-open for large amounts.
Fix: Document this limitation, or reject mixed int/float comparisons on large integers with an Unorderable error to fail closed.
[Critical] builtins/plugins/identity-jwt/src/config.rs - Validation order inconsistency
The validation checks skip_audience_validation && !audiences.is_empty() for conflicting config (line 770), then checks for empty audiences without skip (line 777). An explicitly empty audiences: [] with skip_audience_validation: false will hit the second check, but an omitted audiences field hits the same check. The error messages will differ based on whether the list was omitted vs. explicitly empty, which could confuse operators.
Fix: Reorder checks so "must list at least one audience" comes before the conflict check.
Large Issues
[Large] crates/ppe-apl-core/src/evaluator.rs - Parallel branch panic messages
The panic handling for parallel branches (F1 fix, line ~1220) correctly fails closed but only includes branch index in the error message. Since each branch knows its effect, including the effect type (plugin name, PDP dialect, etc.) would significantly aid debugging.
Fix: Enhance panic message to include identifying information about which effect panicked.
[Large] crates/ppe/src/http_hyper.rs - TOCTOU in SSRF protection
The SSRF protection (F2) correctly checks the resolved IP address, but there's a time-of-check-time-of-use gap if DNS changes between resolution and connection. An attacker controlling DNS could return a safe IP during check, then update to 169.254.169.254 before connection. DNS TTL caching mitigates this but doesn't eliminate it.
Fix: Document this limitation, or re-verify IP immediately before connect if the transport allows.
[Large] builtins/plugins/identity-jwt/src/resolver.rs - Confusing error code for config fault
The new NoAudiences error (F4 fix, line ~1047) uses code auth.no_audiences, but this is a configuration error discovered at request time, not an authentication failure. The token itself may be valid. Operators might be confused why this appears as an auth error rather than a config error.
Fix: Use auth.config_error or ensure this is always caught at config load time (it is in config.rs:777), treating the runtime path as a defensive fallback only.
Coercing integers through f64 for a mixed compare collapsed amounts above 2^53 and a max-amount deny could skip. Those pairs now Deny. Audience load checks the empty-list fault first; emptied audiences at request time use auth.config_error; parallel panic reasons name the effect; the bundled transport documents that it dials the filtered addresses. Signed-off-by: mkoushni <mkoushni@redhat.com>
|
Addressed the five review findings in 68b5114:
|
Bring in the merged APL cleanup (praxis-proxy#55) and unused-item deny (praxis-proxy#44). Keep the security changelog entries; drop unused TokenErrorResponse.error_description now that dead_code is denied. Signed-off-by: mkoushni <mkoushni@redhat.com>
terylt
left a comment
There was a problem hiding this comment.
Hi @mkoushni, Really nice work! I have a few findings that might be worth a look at:
Recommendation: three findings to address before merge. Findings 1 and 2 each leave a finding the write-up records as closed partly open. Finding 3 is an incompleteness in the 2^53 guard rather than a fail-open in the original shape.
Findings
| # | Severity | Location | Claim |
|---|---|---|---|
| 1 | Medium | crates/ppe/src/http_hyper.rs:282 |
The egress refusal never becomes HttpTransportError::Rejected, so a filtered hostname reports idp_unreachable and is retried |
| 2 | Medium | builtins/plugins/identity-jwt/src/resolver.rs:1050 |
A token carrying no aud claim passes a configured audience list |
| 3 | Low | crates/ppe-apl-core/src/evaluator.rs:316 |
The 2^53 exactness guard covers Int and Float but not numeric strings |
Findings 1 and 2 were confirmed by running probes against the branch, not by reading alone. Both probes are reproduced below and were reverted afterwards.
1. The egress refusal never reaches HttpTransportError::Rejected
classify looks for EGRESS_DENIED_PREFIX inside err.to_string():
let msg = err.to_string();
if let Some(reason) = msg.split(EGRESS_DENIED_PREFIX).nth(1) {
return HttpTransportError::Rejected(reason.trim().to_owned());
}hyper_util::client::legacy::Error formats as write!(f, "client error ({:?})", self.kind) (hyper-util 0.1.20, src/client/legacy/client.rs:1628). The source chain is reachable through source() but is not in the Display string, so the marker written at http_hyper.rs:338 never arrives and the branch is dead.
Probe, http://localhost:1/jwks with no hatch, so the name resolves to loopback and EgressResolver drops it:
PROBE err = Connect("client error (Connect)")
The block itself holds. Nothing dials the private address, so this is not an SSRF bypass. What it costs is the three mechanisms built around the distinction:
delegator.rs:305mapsRejectedtodelegation.egress_denied, andapprover.rs:195does the same forelicitation.egress_denied. Atoken_endpointhostname resolving to 10.x now reportsdelegation.idp_unreachable, which is the phantom network problem the comment three lines above says costs an afternoon in DNS.http_retry.rs:157returnsfalseforRejected.Connectfalls through to line 164 and is retried across the whole backoff budget, on every request, against a destination that can never be reached.- The
private_address_reasonstring is discarded.http_addr.rsstates the reason exists so an operator reads "private address" and reaches for the escape hatch instead of a generic denial. They getclient error (Connect).
The CHANGELOG line "The bundled hyper transport produces the refusal" is true for IP literals only.
Suggested fix: walk std::error::Error::source() in classify, or carry a typed error through the resolver and downcast, rather than matching on a formatted string.
The gap is also a test gap. All five new F2 tests use IP literals, which take the pre-connect check at http_hyper.rs:365 and never enter EgressResolver. One hostname case would have caught this.
2. A token with no aud claim passes a configured audience list
validate_token sets the audience but leaves required_spec_claims at its default:
} else {
let aud_refs: Vec<&str> = issuer.audiences.iter().map(String::as_str).collect();
validation.set_audience(&aud_refs);
}jsonwebtoken 10.4 documents on Validation::validate_aud: "Validation only happens if aud claim is present in the token. Adding aud to required_spec_claims will make it required." set_required_spec_claims is never called, so the default stays ["exp"] and a token with the claim absent takes the _ => {} arm of the audience match.
Probe against resolver_on_header, which configures audiences: ["test-aud"]. Token signed by the trusted issuer with iss, sub and exp, no aud:
PROBE no-aud continue_processing = true violation = None
This is a residual of F4's own surface rather than a separate issue. The hatch's doc comment says skip_audience_validation: true accepts a token minted "for any app (or none)", and the knob table at security-analysis.md:178 repeats "Any aud (or none)". Both read as though the default path refuses "none". It does not.
Suggested fix, one line in the else branch:
validation.set_required_spec_claims(&["exp", "iss", "aud"]);Worth a regression test in the same shape as empty_audience_list_rejects_the_token.
3. The 2^53 exactness guard skips numeric strings
coerce_f64_attr and coerce_f64_lit route Int through i64_as_exact_f64 and Float through finite_f64, but the String arm is a bare parse::<f64>() that rounds silently:
AttributeValue::Int(a) => i64_as_exact_f64(*a),
AttributeValue::Float(a) => finite_f64(*a),
AttributeValue::String(s) => s.trim().parse::<f64>().ok().and_then(finite_f64),Probe on args.amount > 9007199254740992: deny:
PROBE string decision = Allow // args.amount = "9007199254740993"
PROBE int decision = Deny // args.amount = Int(9007199254740993)
The comment directly above the coercion gives string arguments as the reason the coercion exists at all ("LLM tool arguments routinely arrive as strings"), so the guard misses the operand shape it was written for. Whether it is worth closing depends on whether amounts above 9e15 are in scope; in minor units they are not unreachable.
Suggested fix: in the String arm, try parse::<i64>() first and send that through i64_as_exact_f64, falling back to f64 only for genuinely fractional strings.
Non-blocking
OrpropagatesUnorderable.evaluator.rs:107uses?, so an unorderable left operand denies even when a right operand would be true. Separately,evaluate_rulesreturnsDenybefore it looks at effects, so an unorderable comparison in a rule whose effect istaintorallowdenies the whole phase. Both are defensible fail-closed choices. The blast radius is wider than "the deny rule fires", and neither is in the operator-visible table.- No CHANGELOG entry for
68b5114(mixed int/float order past 2^53). That is a policy-visible change and belongs beside the F6 bullet. install_default_http_transport(crates/ppe/src/lib.rs:247) does not mention the new refusal orwith_allow_private_destinations. A standalone embedder pointed at a local Keycloak gets an unexplained failure, worse in combination with finding 1.security-analysis.md:86-87says all five F2 tests assertmay_have_reached_peer() == false. Three assert only the variant.- Optional hardening on F3, not a regression.
err.erroris still IdP-controlled text forwarded verbatim, so an IdP that echoessubject_tokenthere still lands it on the violation. Leg 1 has the same shape, so this is consistent rather than new. An allow-list of the RFC 6749 / 8693 codes, or a length cap, would close the stated threat model rather than narrow it.
What was verified and held
- F1, parallel panic.
BranchOutcome::PanickedandTimedOutbecomeDecision::Denywith a labelled reason,Abortedstays a no-op. Readingdispatch_parallel, the ordering is by branch index, and a panic does not short-circuit siblings because the predicate only matchesEffectOutcome::Halt, so an explicit sibling Deny is not displaced by index.TimedOuthas no injection test, which the write-up states. - F5,
!=on a missing attribute.CompareOp::NotEqis produced only from an explicit!=in policy text (parser.rs:690), so no desugaring inherits the new truth value.not inwas already true on a missing key, so the two are now consistent. - F6 and the
Unorderableplumbing. All seveneval_expressioncall sites are converted:evaluate_rules, bothAndandOrwalks,Not,Effect::When, the elicitation scope predicate, and theStage::Redactcondition. No caller outsideevaluator.rsuses the changed helpers. - F7, unreadable handler result. The serial
elseatexecutor.rs:597fires exactly when theif let Some(erased)at:488fails, and itson_errormatch is identical to the existingOk(Err(e))arm. The concurrent path routesNonetoBranchData::Error, which the post-loop already handles per plugin. - F3, leg-2 sanitization. Leg 1 and leg 2 now produce the same shape, and
TokenErrorResponseno longer modelserror_descriptionat all, so serde drops it rather than relying on a call site to omit it. - Tests and lint.
cargo testgreen forpraxis-policy-apl-coreandpraxis-policy-core(697 lib tests),praxis-policy-plugin-identity-jwtandpraxis-policy-plugin-delegator-oauth(375 across all targets).cargo clippy --all-targets -- -D warningsclean for those four crates and forpraxis-policywithhttp-hyper. docs/security-analysis.mdis the strongest part of the PR. It triages rather than lists, names the operator-visible consequence for each accepted fail-open knob, and records what was rejected as a false positive. The "surfaces reviewed without a new accepted finding" section is the part that makes the pass auditable later.
What was not checked
- No live IdP, no real DNS beyond
localhost, and no network egress. Finding 1's probe resolves through the OS resolver to loopback, which is enough to reachEgressResolverbut does not exercise a public name that rebinds. make ciandmake auditwere not run end to end. The PR asserts both are green and the per-crate runs above are consistent with that, but the full two-pass workspace test andcargo denywere not repeated here.- Integration test bodies were read for the changed assertions only, not in full.
Cargo.lockwas not reviewed beyond noting the singletower-serviceaddition.
Reproducing the probes
Worktree at scratchpad/pr45, branch pr-45-review, base 355a586.
Finding 1, appended to crates/ppe/src/http_hyper.rs and run with cargo test -p praxis-policy --features http-hyper --lib:
#[tokio::test]
async fn hostname_resolving_to_loopback_is_rejected_not_connect() {
let err = HyperTransport::new()
.execute(HttpRequest::get("http://localhost:1/jwks"))
.await
.expect_err("localhost resolves to loopback");
assert!(matches!(err, HttpTransportError::Rejected(_)), "got {err:?}");
}Finding 2, appended to the tests module in builtins/plugins/identity-jwt/src/resolver.rs:
#[tokio::test]
async fn a_token_with_no_aud_claim_is_refused() {
let resolver = resolver_on_header("authorization");
let claims = json!({
"iss": "https://idp.example",
"sub": "alice",
"exp": seconds_from_now(3_600),
});
let token = sign_with(b"test-secret", &claims);
let payload = IdentityPayload::new(token, TokenSource::Bearer);
let r = resolver
.handle(&payload, &Extensions::default(), &mut PluginContext::new())
.await;
assert!(!r.continue_processing, "a token with no aud must not pass a configured list");
}Finding 3, appended to the tests module in crates/ppe-apl-core/src/evaluator.rs:
#[test]
fn a_string_encoded_large_int_does_not_round_through_f64() {
let rule = crate::parser::parse_rule("args.amount > 9007199254740992: deny", "test")
.expect("parses");
let mut bag = AttributeBag::new();
bag.set("args.amount", "9007199254740993");
assert!(
matches!(evaluate_rules(std::slice::from_ref(&rule), &bag), Decision::Deny { .. }),
"2^53 + 1 as a string is over the cap"
);
}All three fail on 1cf055b.
|
One thing that came up while verifying this branch, flagging it here so it does not look like a finding against the PR: running |
Hostname egress refusals were Connect because hyper hides the resolver error in source(); a missing aud claim skipped validation; string amounts past 2^53 rounded through f64. Walk the source chain, require aud when a list is configured, and parse integer strings through the exactness bound. Signed-off-by: mkoushni <mkoushni@redhat.com>
|
Addressed the three review findings in 8855038:
Also: F2 tests now assert |
Summary
Closes the first dedicated security review of the engine (#16).
/code-review, Bugbot, and a compound-engineering pass over the priority surfaces (APL evaluator, JWT, OAuth, executor, PDPs, bundled HTTP) produced seven fail-open or leak findings that are fixed here, plus one accepted stub. Dispositions are indocs/security-analysis.md.Fixed
parallel:branch was dropped, so a sibling Allow stood in for a gate that never finished.http_addr(loopback, RFC 1918, link-local/metadata, CGNAT, IPv6 literals including bracketed mapped forms).with_allow_private_destinationsis the hatch for a local IdP.error_descriptionor the raw body (the IdP can echosubject_token).audienceslist no longer disablesaudchecking. Hatch:skip_audience_validation: true.!=on a missing attribute is true, matching!(x == y), sorole != "admin": denyfires when the role is absent."NaN","Infinity","lots", …) Denies an order comparison. Treating the comparison as false skippedargs.amount > 10000: deny;!cannot invert the new Deny into Allow. Missing amounts stay false (F5).on_error: failhalts), not Allow.Accepted with reason
injection.scan/pii.detectare taint markers; they do not inspect the field. Detection lives inplugin(...).on_error: allow, pluginon_error: ignore, cache TTL ceiling,delegation_without_identity_resolutionas an alarm, etc.) are tabulated in the write-up with the operator-visible consequence.Breaking: a JWT issuer config that listed no
audiencesis refused at load unlessskip_audience_validation: trueis set. An order comparison on a non-numeric present value now Denies the phase (previously the comparison was false and a deny rule did not fire).Test plan
"NaN"/"Infinity"/"lots"againstargs.amount > 10000: denyDenies, including under!(...);"5000"still Allowsmake audit(cargo deny check) greenmake cion this PRaudiencesfails at load, andskip_audience_validation: truestill accepts a token minted for another appHyperTransportwithout the hatch rejects169.254.169.254and[::ffff:169.254.169.254]without dialling