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
4853use cpex_core:: extensions:: Extensions ;
4954use 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}
0 commit comments