Skip to content

Commit 5f38887

Browse files
committed
fix: critical and high issues from review.
1 parent ff8c5bd commit 5f38887

9 files changed

Lines changed: 1386 additions & 204 deletions

File tree

crates/apl-cpex/src/session_resolver.rs

Lines changed: 69 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -3,55 +3,56 @@
33
// SPDX-License-Identifier: Apache-2.0
44
// Authors: Teryl Taylor
55
//
6-
// 5-tier session-id resolver. Tiers 1–4 are ported from the Python
7-
// apl-plugins `SessionResolver` (cpex/framework/session.py). Tier 0 is
8-
// a cpex-Rust addition: `AgentExtension.session_id` is the slot any
9-
// upstream plugin/middleware uses to inject an already-resolved
10-
// session id (DB lookup, external service, custom routing). The
11-
// resolver walks these tiers in order, returning the first one that
12-
// hits:
6+
// 3-tier session-id resolver. The Python apl-plugins `SessionResolver`
7+
// (cpex/framework/session.py) shipped a 4-tier version including a
8+
// client-supplied `X-CPEX-Session-Id` header tier. **That tier is
9+
// excluded by design here**: an authenticated client can set the
10+
// header to another subject's known session id and inherit their
11+
// accumulated taint labels, or to a new value and escape their own
12+
// tainted session — defeating `session.labels`-based deny policies
13+
// entirely. The Python comment framed the header as a feature ("lets
14+
// a smart client maintain its own session boundary"); under threat
15+
// modeling it is a privilege-escalation channel with no surviving
16+
// use case the other tiers don't cover. If a future deployment needs
17+
// client-supplied session grouping, the right shape is a subject-
18+
// bound hash (`sha256(subject_id : client_value)`), not the raw
19+
// header value.
20+
//
21+
// The resolver walks these tiers in order, returning the first hit:
1322
//
1423
// 0. `agent` — `AgentExtension.session_id`. A *pre-resolved*
15-
// value: somebody upstream decided what the session is and wrote
16-
// it here. Highest priority because it represents authority, not
17-
// derivation — overriding this with a derived value would discard
18-
// that upstream decision. Plugins that need bespoke session
19-
// resolution (e.g., reading from a separate session-management
20-
// service) write here and let the resolver pick it up.
24+
// value: an upstream plugin or middleware decided what the
25+
// session is and wrote it here. Highest priority because it
26+
// represents authority, not derivation — overriding this with a
27+
// derived value would discard that upstream decision. Plugins
28+
// that need bespoke session resolution (e.g., reading from a
29+
// separate session-management service) write here and let the
30+
// resolver pick it up.
2131
//
2232
// 1. `token_claim` — explicit `session_id` claim in the inbound JWT.
2333
// Strongest binding among the *derived* tiers: the auth issuer
2434
// chose this session and signed it into the token. Read from
2535
// `SecurityExtension.subject.claims["session_id"]`.
2636
//
27-
// 2. `header` — `X-CPEX-Session-Id` request header. Client-
28-
// controlled; lets a smart client maintain its own session boundary
29-
// (e.g., grouping a multi-turn workflow that spans multiple JWTs).
30-
// Read from `HttpExtension.request_headers` (case-insensitive).
31-
//
32-
// 3. `identity` — derived: sha256(sub : caller_workload : this_workload)[:16].
37+
// 2. `identity` — derived: sha256(sub : caller_workload : this_workload)[:16].
3338
// No special infrastructure needed; the triple is already populated
3439
// by `apl-identity-jwt`'s claim mapping. Same user + same agent +
3540
// same gateway = same session, stable across token refresh (the
3641
// claims are stable even when the token string isn't).
3742
//
38-
// 4. `none` — no usable identifier; caller (CmfPluginInvoker)
43+
// 3. `none` — no usable identifier; caller (CmfPluginInvoker)
3944
// skips hydration / persistence. Returns `Ok(None)` so the caller
4045
// can distinguish "no session" from "resolver error" if we ever
4146
// add an error variant.
4247
//
4348
// Each tier reads from a typed `Extensions` field, not raw JWT/HTTP
4449
// payloads — those have already been mapped by upstream identity
45-
// plugins (apl-identity-jwt) and the praxis filter. The resolver
46-
// stays free of crypto/parsing logic.
50+
// plugins (apl-identity-jwt). The resolver stays free of crypto /
51+
// parsing logic.
4752

4853
use cpex_core::extensions::Extensions;
4954
use sha2::{Digest, Sha256};
5055

51-
/// Standard header name. Matches `cpex/framework/session.SESSION_HEADER`
52-
/// from the Python predecessor. Case-insensitive lookup at read time.
53-
const SESSION_HEADER: &str = "x-cpex-session-id";
54-
5556
/// Which tier produced the session id. Useful for diagnostics / audit
5657
/// and to let downstream code branch on binding strength (e.g., only
5758
/// trust `token_claim`-derived sessions for the highest-stakes
@@ -63,8 +64,6 @@ pub enum SessionSource {
6364
Agent,
6465
/// JWT `session_id` claim — strongest binding among derived tiers.
6566
TokenClaim,
66-
/// Client-provided `X-CPEX-Session-Id` header.
67-
Header,
6867
/// Derived from the identity triple. Stable across token refresh.
6968
Identity,
7069
}
@@ -74,7 +73,6 @@ impl SessionSource {
7473
match self {
7574
SessionSource::Agent => "agent",
7675
SessionSource::TokenClaim => "token_claim",
77-
SessionSource::Header => "header",
7876
SessionSource::Identity => "identity",
7977
}
8078
}
@@ -85,7 +83,7 @@ impl SessionSource {
8583
/// every tier comes up empty (anonymous request, no claims, no
8684
/// header, no identity).
8785
///
88-
/// Identity-tier (3) requires at minimum `security.subject.id` to be
86+
/// Identity-tier (2) requires at minimum `security.subject.id` to be
8987
/// populated — without an end-user identifier there's no meaningful
9088
/// session boundary to hash against. The other two identity-triple
9189
/// components (caller_workload, this_workload) fall back to the
@@ -115,17 +113,7 @@ pub fn resolve_session(ext: &Extensions) -> Option<(String, SessionSource)> {
115113
}
116114
}
117115

118-
// Tier 2: client-supplied header. Case-insensitive lookup —
119-
// praxis lowercases, but HTTP/1 spec allows any case.
120-
if let Some(http) = ext.http.as_deref() {
121-
for (k, v) in &http.request_headers {
122-
if k.eq_ignore_ascii_case(SESSION_HEADER) && !v.is_empty() {
123-
return Some((v.clone(), SessionSource::Header));
124-
}
125-
}
126-
}
127-
128-
// Tier 3: identity-derived. Hash the triple
116+
// Tier 2: identity-derived. Hash the triple
129117
// (end-user : calling agent : our gateway) — stable across token
130118
// refresh because all three components survive token rotation.
131119
if let Some(sec) = ext.security.as_deref() {
@@ -161,7 +149,7 @@ pub fn resolve_session(ext: &Extensions) -> Option<(String, SessionSource)> {
161149
}
162150
}
163151

164-
// Tier 4: no session.
152+
// Tier 3: no session.
165153
None
166154
}
167155

@@ -286,53 +274,17 @@ mod tests {
286274
assert_eq!(src, SessionSource::Identity);
287275
}
288276

289-
// --- Tier 2: header ---
277+
// --- Tier 2 (`X-CPEX-Session-Id` header) is intentionally absent ---
278+
//
279+
// The Python `SessionResolver` included a header tier; cpex Rust
280+
// does not. See the module-level doc comment for the threat model.
281+
// A spoofing-regression guard lives below in
282+
// `header_x_cpex_session_id_is_ignored`.
290283

291-
#[test]
292-
fn tier2_header_hits_when_no_token_claim() {
293-
let sec = SecurityExtension {
294-
subject: Some(subject_with_claims(Some("alice"), &[])),
295-
..Default::default()
296-
};
297-
let mut http = HttpExtension::default();
298-
http.request_headers
299-
.insert("X-CPEX-Session-Id".into(), "sess-from-header".into());
300-
let ext = Extensions {
301-
security: Some(Arc::new(sec)),
302-
http: Some(Arc::new(http)),
303-
..Default::default()
304-
};
305-
306-
let (sid, src) = resolve_session(&ext).expect("should resolve");
307-
assert_eq!(sid, "sess-from-header");
308-
assert_eq!(src, SessionSource::Header);
309-
}
284+
// --- Tier 2: identity ---
310285

311286
#[test]
312-
fn tier2_header_lookup_is_case_insensitive() {
313-
// Praxis lowercases all headers; cpex shouldn't care.
314-
let sec = SecurityExtension {
315-
subject: Some(subject_with_claims(Some("alice"), &[])),
316-
..Default::default()
317-
};
318-
let mut http = HttpExtension::default();
319-
http.request_headers
320-
.insert("x-cpex-session-id".into(), "lowercase-wins".into());
321-
let ext = Extensions {
322-
security: Some(Arc::new(sec)),
323-
http: Some(Arc::new(http)),
324-
..Default::default()
325-
};
326-
327-
let (sid, src) = resolve_session(&ext).expect("should resolve");
328-
assert_eq!(sid, "lowercase-wins");
329-
assert_eq!(src, SessionSource::Header);
330-
}
331-
332-
// --- Tier 3: identity ---
333-
334-
#[test]
335-
fn tier3_identity_derived_when_no_claim_no_header() {
287+
fn tier2_identity_derived_when_no_claim() {
336288
let sec = SecurityExtension {
337289
subject: Some(subject_with_claims(Some("alice@corp.com"), &[])),
338290
caller_workload: Some(WorkloadIdentity {
@@ -355,7 +307,7 @@ mod tests {
355307
}
356308

357309
#[test]
358-
fn tier3_identity_is_stable_across_calls() {
310+
fn tier2_identity_is_stable_across_calls() {
359311
// Same triple → same session id. Property guarantees that
360312
// a token refresh (which doesn't change sub/caller/this) keeps
361313
// the session intact.
@@ -381,7 +333,7 @@ mod tests {
381333
}
382334

383335
#[test]
384-
fn tier3_distinguishes_different_users() {
336+
fn tier2_distinguishes_different_users() {
385337
let alice = SecurityExtension {
386338
subject: Some(subject_with_claims(Some("alice"), &[])),
387339
..Default::default()
@@ -396,7 +348,7 @@ mod tests {
396348
}
397349

398350
#[test]
399-
fn tier3_distinguishes_different_agents() {
351+
fn tier2_distinguishes_different_agents() {
400352
// Same user, two different agents → different sessions.
401353
// Important so a malicious agent's accumulated taints don't
402354
// affect a different agent that user runs.
@@ -415,18 +367,18 @@ mod tests {
415367
assert_ne!(sid1, sid2);
416368
}
417369

418-
// --- Tier 4: none ---
370+
// --- Tier 3: none ---
419371

420372
#[test]
421-
fn tier4_no_session_when_no_data() {
373+
fn tier3_no_session_when_no_data() {
422374
let ext = Extensions::default();
423375
assert!(resolve_session(&ext).is_none());
424376
}
425377

426378
#[test]
427-
fn tier4_no_session_when_no_subject_id() {
379+
fn tier3_no_session_when_no_subject_id() {
428380
// Security exists but no subject id → identity can't hash.
429-
// Header / claim are absent too. Should be None.
381+
// Claim is absent too. Should be None.
430382
let sec = SecurityExtension {
431383
subject: Some(SubjectExtension::default()), // id = None
432384
..Default::default()
@@ -435,52 +387,46 @@ mod tests {
435387
assert!(resolve_session(&ext).is_none());
436388
}
437389

438-
// --- Tier ordering ---
439-
440-
#[test]
441-
fn token_claim_wins_over_header() {
442-
let sec = SecurityExtension {
443-
subject: Some(subject_with_claims(
444-
Some("alice"),
445-
&[("session_id", "from-token")],
446-
)),
447-
..Default::default()
448-
};
449-
let mut http = HttpExtension::default();
450-
http.request_headers
451-
.insert("x-cpex-session-id".into(), "from-header".into());
452-
let ext = Extensions {
453-
security: Some(Arc::new(sec)),
454-
http: Some(Arc::new(http)),
455-
..Default::default()
456-
};
457-
458-
let (sid, src) = resolve_session(&ext).unwrap();
459-
assert_eq!(sid, "from-token");
460-
assert_eq!(src, SessionSource::TokenClaim);
461-
}
390+
// --- Spoofing guard (regression test for P0-2) ---
462391

463392
#[test]
464-
fn header_wins_over_identity() {
393+
fn header_x_cpex_session_id_is_ignored() {
394+
// The Python apl-plugins resolver honored an `X-CPEX-Session-Id`
395+
// header tier between token_claim and identity. We deliberately
396+
// dropped it: an authenticated client could set the header to
397+
// another subject's session id and inherit their accumulated
398+
// taints, or to a random unused value and escape their own
399+
// tainted session. This test pins that behaviour: the header is
400+
// present, no token claim exists, and the resolver still falls
401+
// through to identity-derived (or none) rather than honoring
402+
// the header. If a future PR adds a header tier without
403+
// subject binding, this test fails.
465404
let sec = SecurityExtension {
466405
subject: Some(subject_with_claims(Some("alice"), &[])),
467406
caller_workload: Some(WorkloadIdentity {
468-
client_id: Some("would-derive-this".into()),
407+
client_id: Some("agent-007".into()),
469408
..Default::default()
470409
}),
471410
..Default::default()
472411
};
473412
let mut http = HttpExtension::default();
474413
http.request_headers
475-
.insert("x-cpex-session-id".into(), "from-header".into());
414+
.insert("X-CPEX-Session-Id".into(), "sess-bob-stolen".into());
476415
let ext = Extensions {
477416
security: Some(Arc::new(sec)),
478417
http: Some(Arc::new(http)),
479418
..Default::default()
480419
};
481420

482-
let (sid, src) = resolve_session(&ext).unwrap();
483-
assert_eq!(sid, "from-header");
484-
assert_eq!(src, SessionSource::Header);
421+
let (sid, src) = resolve_session(&ext).expect("identity should still hit");
422+
assert_eq!(
423+
src,
424+
SessionSource::Identity,
425+
"header tier was removed; resolver must NOT honor X-CPEX-Session-Id",
426+
);
427+
assert_ne!(
428+
sid, "sess-bob-stolen",
429+
"header value must never become the session id",
430+
);
485431
}
486432
}

crates/apl-delegator-oauth/src/config.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,16 @@ pub struct OAuthDelegatorConfig {
5252
/// want a different header (`X-Service-Token`, etc.).
5353
#[serde(default = "default_outbound_header")]
5454
pub default_outbound_header: String,
55+
56+
/// Explicitly allow `http://` for `token_endpoint`. By default,
57+
/// the constructor rejects non-https URLs because the
58+
/// token-exchange POST sends `client_id:client_secret` and the
59+
/// inbound user JWT — leaking either over plaintext defeats the
60+
/// whole exchange. Set to `true` ONLY for `http://localhost`
61+
/// development against a docker-compose IdP. Production
62+
/// deployments must leave this at the default (`false`).
63+
#[serde(default)]
64+
pub insecure_http: bool,
5565
}
5666

5767
/// Where the gateway's OAuth client secret is loaded from. Three

0 commit comments

Comments
 (0)