From f4149275b76174f2a29c521911dfe8690c74e593 Mon Sep 17 00:00:00 2001 From: Rita Agafonova <36133358+rita-aga@users.noreply.github.com> Date: Tue, 7 Jul 2026 02:07:39 -0700 Subject: [PATCH 1/2] fix(auth): reject self-asserted principal headers from external callers (ARN-167) TemperPaw trusted any request carrying x-temper-principal-kind/-id as a PreAuthenticatedRequest, so any external client could self-assert admin. Add an ingress edge that strips all client-asserted identity headers (x-temper-*, x-agent-id, x-tenant-id) unless the request originates from a genuine loopback peer (checked via ConnectInfo, never a forwarding header). Only a loopback-origin internal call may self-assert its principal; external callers have those headers removed before Cedar/bearer can trust them. TemperPaw side of systemic Class A; kernel counterpart is ARN-170 (temper #343). ADR-0066. Co-Authored-By: Claude Fable 5 --- crates/temperpaw/src/auth.rs | 207 +++++++++++++++++- crates/temperpaw/src/startup.rs | 11 +- ...-reject-self-asserted-principal-headers.md | 102 +++++++++ 3 files changed, 314 insertions(+), 6 deletions(-) create mode 100644 docs/adrs/0066-reject-self-asserted-principal-headers.md diff --git a/crates/temperpaw/src/auth.rs b/crates/temperpaw/src/auth.rs index ee8921c83..e3d9d24bd 100644 --- a/crates/temperpaw/src/auth.rs +++ b/crates/temperpaw/src/auth.rs @@ -1,6 +1,7 @@ //! Authentication routes and middleware for the embedded dashboard. use std::collections::BTreeMap; +use std::net::SocketAddr; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -9,7 +10,7 @@ use argon2::Argon2; use argon2::password_hash::rand_core::OsRng; use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; use axum::body::Body; -use axum::extract::State; +use axum::extract::{ConnectInfo, State}; use axum::http::header::COOKIE; use axum::http::{HeaderMap, HeaderValue, Request, StatusCode}; use axum::middleware::Next; @@ -123,6 +124,22 @@ pub fn router(state: AuthState) -> Router { .with_state(state) } +/// Prefix covering the full family of headers the kernel's Cedar principal +/// builder (`SecurityContext::from_headers`) trusts to establish identity, +/// delegation, scope, and context: principal id/kind, `agent-role`, +/// `agent-type`, `acting-for` (delegation), `principal-scopes`, `action-context`, +/// and arbitrary `x-temper-attr-*` / `x-temper-ctx-*` / `x-temper-span-attr-*` +/// attributes. A remote client must never be able to set any of these; they are +/// honored only when injected server-side (cookie/bearer resolution) or +/// presented by a genuinely internal loopback caller. TemperPaw is the sole +/// network edge in front of the kernel, which does not itself strip these, so +/// the whole prefix is removed at the ingress edge for every external request. +const CLIENT_ASSERTABLE_IDENTITY_HEADER_PREFIX: &str = "x-temper-"; + +/// Additional exact header names outside the `x-temper-*` prefix that also carry +/// client-assertable identity/tenant selection. +const CLIENT_ASSERTABLE_IDENTITY_HEADERS: [&str; 2] = ["x-agent-id", "x-tenant-id"]; + pub async fn middleware( State(state): State, mut request: Request, @@ -131,6 +148,17 @@ pub async fn middleware( let path = request.uri().path().to_string(); let method = request.method().as_str().to_string(); + let from_internal_loopback = request_is_loopback(&request); + + // Ingress edge: a request that did not originate from a trusted in-process + // (loopback) caller must never carry client-asserted identity headers. Strip + // them before Cedar or the kernel bearer check can trust them. Identity for + // external requests is re-derived below only from a resolved credential + // (session cookie or bearer token). + if !from_internal_loopback { + strip_client_identity_headers(request.headers_mut()); + } + if is_safe_setup_path_public_during_bootstrap(&state, &method, &path).await || is_public_path(request.method().as_str(), &path) { @@ -148,10 +176,14 @@ pub async fn middleware( return next.run(request).await; } - // Internal WASM agent calls carry principal headers but no Bearer token - // or session cookie. Mark as pre-authenticated so Temper's bearer_auth_check - // passes through. - if request.headers().contains_key("x-temper-principal-kind") + // Internal WASM agent / transport calls arrive over loopback carrying + // principal headers but no Bearer token or session cookie. Only a genuinely + // loopback-origin request may self-assert its principal this way; external + // callers had these headers stripped above, so they cannot reach this branch + // with forged identity. Mark as pre-authenticated so Temper's + // bearer_auth_check passes through. + if from_internal_loopback + && request.headers().contains_key("x-temper-principal-kind") && request.headers().contains_key("x-temper-principal-id") { ensure_tenant_header(request.headers_mut(), state.tenant()); @@ -166,6 +198,38 @@ pub async fn middleware( StatusCode::UNAUTHORIZED.into_response() } +/// True when the underlying TCP peer is loopback (`127.0.0.0/8` or `::1`), i.e. +/// the request came from another process inside this container rather than an +/// external client via the network edge. Derived from the real connection peer +/// address (`ConnectInfo`), never from a client-supplied forwarding header. +/// Absent connection info (e.g. in-process test transports) is treated as NOT +/// loopback — the safe default. +fn request_is_loopback(request: &Request) -> bool { + request + .extensions() + .get::>() + .map(|ConnectInfo(addr)| addr.ip().is_loopback()) + .unwrap_or(false) +} + +/// Remove any client-asserted identity/tenant headers so downstream layers +/// (Cedar, the kernel bearer check) cannot mistake them for a server-resolved +/// principal. Strips the entire `x-temper-*` family plus the extra exact names. +fn strip_client_identity_headers(headers: &mut HeaderMap) { + let forged: Vec = headers + .keys() + .filter(|name| { + let name = name.as_str(); + name.starts_with(CLIENT_ASSERTABLE_IDENTITY_HEADER_PREFIX) + || CLIENT_ASSERTABLE_IDENTITY_HEADERS.contains(&name) + }) + .cloned() + .collect(); + for name in forged { + headers.remove(&name); + } +} + async fn is_safe_setup_path_public_during_bootstrap( state: &AuthState, method: &str, @@ -618,7 +682,10 @@ enum AuthError { #[cfg(test)] mod tests { + use std::net::SocketAddr; + use axum::body::{Body, to_bytes}; + use axum::extract::ConnectInfo; use axum::http::HeaderMap; use axum::http::{Request, StatusCode}; use axum::middleware::from_fn_with_state; @@ -931,4 +998,134 @@ mod tests { let authenticated_response = app.oneshot(authenticated_request).await.unwrap(); assert_eq!(authenticated_response.status(), StatusCode::OK); } + + #[tokio::test] + async fn external_self_asserted_principal_headers_are_rejected() { + async fn tdata_handler() -> impl IntoResponse { + StatusCode::OK + } + + let tempdir = tempfile::tempdir().unwrap(); + let state = AuthState::for_tests(tempdir.path()).await; + let app = Router::new() + .route("/tdata/Agents", get(tdata_handler)) + .merge(router(state.clone())) + .layer(from_fn_with_state(state.clone(), middleware)); + + // A remote client self-asserting an admin principal, with the real TCP + // peer being a public (non-loopback) address and no session cookie. + let mut request = Request::builder() + .method("GET") + .uri("/tdata/Agents") + .header("x-temper-principal-kind", "admin") + .header("x-temper-principal-id", "attacker") + .header("x-tenant-id", "default") + .body(Body::empty()) + .unwrap(); + request + .extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([203, 0, 113, 7], 44321)))); + + let response = app.oneshot(request).await.unwrap(); + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "external self-asserted principal headers must not authenticate as admin" + ); + } + + #[tokio::test] + async fn external_forged_identity_family_is_stripped_before_downstream() { + // A public path passes through the middleware to the handler, so this + // proves the full x-temper-* family (not just principal id/kind) is + // removed for external callers before Cedar or a public handler sees it. + async fn probe_handler(headers: HeaderMap) -> impl IntoResponse { + let surviving: Vec = headers + .keys() + .map(|key| key.as_str().to_string()) + .filter(|key| { + key.starts_with("x-temper-") || key == "x-agent-id" || key == "x-tenant-id" + }) + .collect(); + (StatusCode::OK, Json(json!({ "surviving": surviving }))) + } + + let tempdir = tempfile::tempdir().unwrap(); + let state = AuthState::for_tests(tempdir.path()).await; + let app = Router::new() + .route("/triggers/webhook/test", get(probe_handler)) + .merge(router(state.clone())) + .layer(from_fn_with_state(state.clone(), middleware)); + + let mut request = Request::builder() + .method("GET") + .uri("/triggers/webhook/test") + .header("x-temper-principal-kind", "admin") + .header("x-temper-principal-id", "attacker") + .header("x-temper-agent-role", "admin") + .header("x-temper-acting-for", "victim") + .header("x-temper-principal-scopes", "*") + .header("x-temper-attr-clearance", "top-secret") + .header("x-temper-ctx-agentTypeVerified", "true") + .header("x-agent-id", "attacker") + .header("x-tenant-id", "other-tenant") + .body(Body::empty()) + .unwrap(); + request + .extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([203, 0, 113, 7], 5555)))); + + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let payload: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + payload["surviving"].as_array().map(|values| values.len()), + Some(0), + "no client-asserted identity headers may survive to a downstream handler: {payload}" + ); + } + + #[tokio::test] + async fn internal_loopback_self_asserted_principal_headers_are_admitted() { + async fn tdata_handler(headers: HeaderMap) -> impl IntoResponse { + let kind = headers + .get("x-temper-principal-kind") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + let id = headers + .get("x-temper-principal-id") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + (StatusCode::OK, Json(json!({ "kind": kind, "id": id }))) + } + + let tempdir = tempfile::tempdir().unwrap(); + let state = AuthState::for_tests(tempdir.path()).await; + let app = Router::new() + .route("/tdata/Agents", get(tdata_handler)) + .merge(router(state.clone())) + .layer(from_fn_with_state(state.clone(), middleware)); + + // A genuinely internal caller: real TCP peer is loopback. Internal + // transport/setup/startup callers reach the server this way. + let mut request = Request::builder() + .method("GET") + .uri("/tdata/Agents") + .header("x-temper-principal-kind", "admin") + .header("x-temper-principal-id", "temperpaw-transport") + .body(Body::empty()) + .unwrap(); + request + .extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 44321)))); + + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let payload: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(payload["kind"], "admin"); + assert_eq!(payload["id"], "temperpaw-transport"); + } } diff --git a/crates/temperpaw/src/startup.rs b/crates/temperpaw/src/startup.rs index a0bd74f1c..7e9b4d28d 100644 --- a/crates/temperpaw/src/startup.rs +++ b/crates/temperpaw/src/startup.rs @@ -578,7 +578,16 @@ fn spawn_runtime_server( listener: tokio::net::TcpListener, router: axum::Router, ) -> JoinHandle> { - tokio::spawn(async move { axum::serve(listener, router).await }) + // Serve with connection info so the auth middleware can read the real TCP + // peer address and distinguish genuinely internal (loopback) callers from + // external ones. See ADR-0066. + tokio::spawn(async move { + axum::serve( + listener, + router.into_make_service_with_connect_info::(), + ) + .await + }) } async fn startup_gate_middleware( diff --git a/docs/adrs/0066-reject-self-asserted-principal-headers.md b/docs/adrs/0066-reject-self-asserted-principal-headers.md new file mode 100644 index 000000000..8eac1c06d --- /dev/null +++ b/docs/adrs/0066-reject-self-asserted-principal-headers.md @@ -0,0 +1,102 @@ +# ADR-0066: Reject self-asserted principal headers at the ingress edge + +## Status + +Accepted. + +## Context + +The embedded dashboard/API auth middleware (`crates/temperpaw/src/auth.rs`) +treated **any** request carrying both `x-temper-principal-kind` and +`x-temper-principal-id` as a `PreAuthenticatedRequest` and passed the +client-supplied principal downstream — with no cookie, no bearer token, and no +check that the caller was actually internal. The code comment claimed the path +was for "internal WASM agent calls," but nothing verified the call was internal. + +The production server binds `0.0.0.0` and Railway deploys it as the network +edge with no header-stripping proxy in front of it. Client-supplied +`x-temper-*` headers therefore reached the app untouched. Any external caller +could self-assert an admin principal: + +``` +curl https:///tdata/ \ + -H 'x-temper-principal-kind: admin' \ + -H 'x-temper-principal-id: attacker' \ + -H 'x-tenant-id: default' +``` + +Cedar was the only remaining gate, and the client also controlled `x-tenant-id`. +This is the TemperPaw instance of the systemic **Class A** self-asserted-identity +bypass (epic ARN-165); the kernel counterpart was ARN-170 (temper PR #343). +Tracked as ARN-167. + +Legitimate internal callers already model "internal" as **loopback**: the +transport client (`crates/paw-transport`), setup, startup, and observer callers +all target `http://127.0.0.1:{port}` and only attach admin principal headers on +loopback URLs (`PawApiClient::uses_internal_loopback`). Remote agents/workers +authenticate with a Bearer token. So the property that distinguishes an internal +caller from an external one is the real TCP peer being loopback — something a +remote client cannot forge. + +## Decision + +Identity for external requests is derived **only** from a resolved credential; +self-asserted identity headers are never trusted from a remote peer. Two changes +implement this at the ingress edge: + +1. **Strip client-asserted identity headers from every non-loopback request.** + Before any downstream logic (Cedar, the kernel `bearer_auth_check`) can read + them, the middleware removes the **entire `x-temper-*` header family** plus + `x-agent-id` and `x-tenant-id` from any request whose TCP peer is not + loopback. The whole prefix is stripped — not a hardcoded subset — because the + kernel's Cedar principal builder (`SecurityContext::from_headers` in + temper-authz) trusts far more than principal id/kind: it derives + `principal.role` from `x-temper-agent-role`, delegation from + `x-temper-acting-for`, scopes from `x-temper-principal-scopes`, arbitrary + principal attributes from `x-temper-attr-*`, and Cedar context attributes + (including `agentTypeVerified`) from `x-temper-ctx-*`. TemperPaw is the sole + network edge in front of the kernel and the kernel does not itself strip these, + so anything less than a full-family strip would leave a privilege-escalation + surface for any caller holding a valid low-privilege credential. The tenant and + principal are then re-derived server-side from the resolved credential (session + cookie injects the admin principal; bearer is resolved by the kernel), and the + deployment tenant is forced via `ensure_tenant_header`. + +2. **Honor the header-only "internal" path only for genuinely loopback peers.** + The branch that marks a request `PreAuthenticated` purely from the presence of + principal headers now additionally requires the request to originate from a + loopback peer. The peer is read from the real connection address + (`ConnectInfo`), never from a client-supplied forwarding header. + To make that address available, the runtime server is now served with + `into_make_service_with_connect_info::()`. When connection info is + absent (e.g. in-process test transports) the request is treated as **not** + loopback — the safe default. + +External callers therefore have no way to reach the pre-authenticated branch with +forged identity: their headers are stripped, and they are not loopback. Internal +loopback callers and cookie/bearer-authenticated callers are unaffected. + +## Alternatives considered + +- **Internal shared secret / in-process marker instead of a loopback check.** + Topology-independent and forge-proof regardless of network layout, but it + requires threading a startup-generated secret through every internal caller, + including the separate `paw-codex-worker` process, for a larger change surface. + The loopback check reuses the convention internal callers already follow + (`uses_internal_loopback`) and needs no secret distribution. A shared secret + remains a viable hardening follow-up if the internal-call topology ever stops + being loopback. + +## Consequences + +- Remote requests must present a session cookie or a Bearer token; self-asserted + `x-temper-*`/`x-tenant-id` headers from remote peers are ignored (stripped). +- In-container loopback callers (transport, setup, startup, observer) continue to + work unchanged because their real peer is `127.0.0.1`/`::1`. +- The runtime server now propagates connection info; this is additive and does + not change routing. +- **Residual risk:** the loopback check assumes external traffic never reaches the + app over a loopback peer. This holds on the current Railway topology (the app is + the edge; no same-container proxy forwards over loopback). If that topology + changes, or a `paw-codex-worker` runs remotely without a Bearer token, the + shared-secret hardening above should be adopted. From 152936c7a3f58fe0c74178eeac06b055ef97f8ee Mon Sep 17 00:00:00 2001 From: rita-aga Date: Sat, 11 Jul 2026 18:37:24 -0700 Subject: [PATCH 2/2] fix(auth): reject loopback self-asserted identity --- ...260709_arn_167_credential_bound_ingress.md | 28 +++ .proofs/arn-167-credential-bound-ingress.md | 88 +++++++ crates/paw-transport/src/lib.rs | 231 +++++------------- crates/temperpaw/src/auth.rs | 129 +++++----- crates/temperpaw/src/setup.rs | 5 - crates/temperpaw/src/startup.rs | 27 +- ...-reject-self-asserted-principal-headers.md | 141 +++++------ 7 files changed, 301 insertions(+), 348 deletions(-) create mode 100644 .progress/002_20260709_arn_167_credential_bound_ingress.md create mode 100644 .proofs/arn-167-credential-bound-ingress.md diff --git a/.progress/002_20260709_arn_167_credential_bound_ingress.md b/.progress/002_20260709_arn_167_credential_bound_ingress.md new file mode 100644 index 000000000..e43261428 --- /dev/null +++ b/.progress/002_20260709_arn_167_credential_bound_ingress.md @@ -0,0 +1,28 @@ +# ARN-167: Credential-bound TemperPaw ingress identity + +## Objective + +Reject every client-asserted identity at the TemperPaw HTTP edge, including +loopback callers, while preserving internal transport and startup behavior +through credential-derived identity. + +## Plan + +1. Revise ADR-0066 so network location is defense in depth, never proof of + identity. +2. Add red tests proving loopback self-assertion is rejected and loopback API + clients use bearer credentials without principal headers. +3. Remove the loopback pre-authentication branch and its connection-info + plumbing; strip the entire client-assertable identity family on every + request before resolving a session or bearer credential. +4. Remove obsolete client-side loopback identity synthesis. +5. Run focused tests, full affected-crate tests, a live local HTTP flow, Clippy, + formatting, and independent review. + +## Acceptance criteria + +- Loopback plus raw admin headers and no credential returns 401. +- Remote and loopback requests cannot preserve forged identity attributes. +- A valid session is injected server-side after stripping. +- Internal `PawApiClient` calls use the configured bearer token on loopback. +- No IP/hostname allowlist or duplicate authentication implementation remains. diff --git a/.proofs/arn-167-credential-bound-ingress.md b/.proofs/arn-167-credential-bound-ingress.md new file mode 100644 index 000000000..7f6346bb5 --- /dev/null +++ b/.proofs/arn-167-credential-bound-ingress.md @@ -0,0 +1,88 @@ +# Proof Report: ARN-167 — Credential-bound ingress identity + +## Date + +2026-07-09 + +## Branch / Commit + +`codex/pr452-review-fixes` based on PR #452 head +`f4149275b76174f2a29c521911dfe8690c74e593`. + +## What Was Done + +- Removed loopback-address authentication and the corresponding connection-info + server plumbing. +- Stripped all client-assertable identity and tenant headers for every peer. +- Kept session identity injection server-side and bearer identity resolution in + the kernel. +- Changed `PawApiClient` to use its configured bearer token on loopback and to + synthesize no principal headers. +- Removed obsolete self-asserted admin headers from setup and startup clients. + +## Verification Flow + +1. Wrote rejection and credential-binding tests against the vulnerable PR. +2. Confirmed both loopback auth tests failed: self-assertion returned 200 and all + nine forged identity-family headers survived. +3. Confirmed both pure transport request tests failed because the client emitted + raw admin headers and omitted bearer auth. +4. Implemented the credential-only boundary. +5. Ran the complete auth test module, the transport header/trace suite, the full + TemperPaw crate test suite, formatting, diff-check, and Clippy with warnings + denied. + +## Verification Results + +| Step | Expected | Actual | Status | +|------|----------|--------|--------| +| Red: loopback self-assertion | New tests fail on old behavior | 200 vs 401; 9 forged headers survived | Pass | +| Red: transport identity | New pure tests fail on old behavior | Raw admin headers present; bearer absent | Pass | +| Auth module | Credential-bound flows pass | 11 passed, 0 failed | Pass | +| Combined auth chain | Global bearer replaces forged identity | `admin/api-key-holder/default` | Pass | +| Transport request construction | No principal headers; bearer on loopback | 3 passed, 0 failed | Pass | +| Full TemperPaw crate suite | All tests pass | 77 unit tests plus all integration contract tests passed | Pass | +| Formatting / diff hygiene | No drift | `cargo fmt --check` and `git diff --check` passed | Pass | +| Clippy | No warnings | `-D warnings` passed for both crates/all targets | Pass | + +## What Worked + +- The full in-process Axum chain exercises TemperPaw ingress middleware followed + by the real kernel bearer middleware, without a mock authentication decision. +- Session-cookie identity overrides forged client headers after stripping. +- The remediation deletes more code than it adds and leaves one credential path. + +## Limitations + +No deployed Railway/Datadog verification was performed because the PR must remain +open for human review. + +## What Still Doesn't Work + +- Push/update PR #452 and request Greptile as `nerdsane`. +- Run deployed verification after human review/merge. +- Deployed verification is intentionally pending human merge. + +## Artifacts + +- `cargo test --locked -p temperpaw auth::tests -- --nocapture` +- `cargo test --locked -p paw-transport tests::paw_api_client_ -- --nocapture` +- `cargo test --locked -p temperpaw` +- `cargo fmt --check` +- `git diff --check` +- `cargo clippy --locked -p temperpaw -p paw-transport --all-targets -- -D warnings` + +## Architecture Diagram + +```text +network peer (remote or loopback) + | + v +strip client identity + tenant headers + | + +-- valid session --> inject authenticated dashboard principal + | + +-- bearer token --> kernel resolves registered agent or API-key admin + | + `-- neither ------> 401 on protected routes +``` diff --git a/crates/paw-transport/src/lib.rs b/crates/paw-transport/src/lib.rs index 300d572aa..fe13aff9b 100644 --- a/crates/paw-transport/src/lib.rs +++ b/crates/paw-transport/src/lib.rs @@ -463,34 +463,20 @@ impl PawApiClient { fn build_request(&self, method: reqwest::Method, url: &str) -> reqwest::RequestBuilder { let mut req = self.http.request(method, url); req = req.header("x-tenant-id", &self.config.tenant); - if self.uses_internal_loopback(url) { - req = req.header("x-temper-principal-kind", "admin"); - req = req.header("x-temper-principal-id", "temperpaw-transport"); - } else if let Some(ref key) = self.config.api_key { + if let Some(ref key) = self.config.api_key { req = req.header("authorization", format!("Bearer {key}")); - } else { - req = req.header("x-temper-principal-kind", "admin"); - req = req.header("x-temper-principal-id", "temperpaw-transport"); } if let Some(traceparent) = current_traceparent_header() { req = req.header("traceparent", traceparent); } req } - - fn uses_internal_loopback(&self, url: &str) -> bool { - reqwest::Url::parse(url) - .ok() - .and_then(|parsed| parsed.host_str().map(|host| host.to_ascii_lowercase())) - .map(|host| host == "127.0.0.1" || host == "::1" || host == "localhost") - .unwrap_or(false) - } } #[cfg(test)] mod tests { use axum::extract::State; - use axum::http::{HeaderMap, StatusCode, Uri}; + use axum::http::{StatusCode, Uri}; use axum::routing::{get, post}; use axum::{Json, Router}; use opentelemetry::trace::{TraceContextExt, TracerProvider as _}; @@ -503,15 +489,6 @@ mod tests { use super::{ApprovalScope, PawApiClient, PawApiConfig, approval_body_for_scope}; - #[derive(Clone, Default)] - struct HeaderProbe { - last_kind: Arc>>, - last_id: Arc>>, - last_tenant: Arc>>, - last_auth: Arc>>, - last_traceparent: Arc>>, - } - #[derive(Clone, Default)] struct QueryProbe { last_uri: Arc>>, @@ -526,136 +503,65 @@ mod tests { format!("http://{}", addr) } - #[tokio::test] - async fn paw_api_client_without_api_key_includes_internal_admin_identity() { - let probe = HeaderProbe::default(); - let app = Router::new() - .route( - "/tdata/Channels", - post( - |State(probe): State, headers: HeaderMap| async move { - *probe.last_kind.lock().unwrap() = headers - .get("x-temper-principal-kind") - .and_then(|v| v.to_str().ok()) - .map(|v| v.to_string()); - *probe.last_id.lock().unwrap() = headers - .get("x-temper-principal-id") - .and_then(|v| v.to_str().ok()) - .map(|v| v.to_string()); - *probe.last_tenant.lock().unwrap() = headers - .get("x-tenant-id") - .and_then(|v| v.to_str().ok()) - .map(|v| v.to_string()); - *probe.last_auth.lock().unwrap() = headers - .get("authorization") - .and_then(|v| v.to_str().ok()) - .map(|v| v.to_string()); - - ( - StatusCode::CREATED, - Json(json!({"entity_id":"ch_123","ChannelType":"discord"})), - ) - }, - ), - ) - .with_state(probe.clone()); - - let base_url = spawn_test_server(app).await; + #[test] + fn paw_api_client_without_api_key_does_not_assert_identity() { let client = PawApiClient::new(PawApiConfig { - base_url, + base_url: "http://127.0.0.1:3497".to_string(), tenant: "default".to_string(), api_key: None, }); - let created = client - .create_entity("Channels", json!({"ChannelType":"discord"})) - .await - .unwrap(); + let request = client + .build_request( + reqwest::Method::POST, + "http://127.0.0.1:3497/tdata/Channels", + ) + .build() + .expect("build request"); assert_eq!( - created.get("entity_id").and_then(|v| v.as_str()), - Some("ch_123") - ); - assert_eq!( - probe.last_kind.lock().unwrap().as_deref(), - Some("admin"), - "internal loopback calls should advertise admin principal kind", + request + .headers() + .get("x-tenant-id") + .and_then(|value| value.to_str().ok()), + Some("default") ); - assert_eq!( - probe.last_id.lock().unwrap().as_deref(), - Some("temperpaw-transport"), - "internal loopback calls must include a principal id so auth middleware treats them as pre-authenticated", - ); - assert_eq!( - probe.last_tenant.lock().unwrap().as_deref(), - Some("default"), - ); - assert_eq!(probe.last_auth.lock().unwrap().as_deref(), None); + assert!(!request.headers().contains_key("x-temper-principal-kind")); + assert!(!request.headers().contains_key("x-temper-principal-id")); + assert!(!request.headers().contains_key("authorization")); } - #[tokio::test] - async fn paw_api_client_with_api_key_still_uses_internal_admin_identity_for_loopback() { - let probe = HeaderProbe::default(); - let app = Router::new() - .route( - "/tdata/Channels", - post( - |State(probe): State, headers: HeaderMap| async move { - *probe.last_kind.lock().unwrap() = headers - .get("x-temper-principal-kind") - .and_then(|v| v.to_str().ok()) - .map(|v| v.to_string()); - *probe.last_id.lock().unwrap() = headers - .get("x-temper-principal-id") - .and_then(|v| v.to_str().ok()) - .map(|v| v.to_string()); - *probe.last_tenant.lock().unwrap() = headers - .get("x-tenant-id") - .and_then(|v| v.to_str().ok()) - .map(|v| v.to_string()); - *probe.last_auth.lock().unwrap() = headers - .get("authorization") - .and_then(|v| v.to_str().ok()) - .map(|v| v.to_string()); - - ( - StatusCode::CREATED, - Json(json!({"entity_id":"ch_456","ChannelType":"discord"})), - ) - }, - ), - ) - .with_state(probe.clone()); - - let base_url = spawn_test_server(app).await; + #[test] + fn paw_api_client_with_api_key_uses_bearer_on_loopback() { let client = PawApiClient::new(PawApiConfig { - base_url, + base_url: "http://127.0.0.1:3497".to_string(), tenant: "default".to_string(), api_key: Some("test-token".to_string()), }); - let created = client - .create_entity("Channels", json!({"ChannelType":"discord"})) - .await - .unwrap(); + let request = client + .build_request( + reqwest::Method::POST, + "http://127.0.0.1:3497/tdata/Channels", + ) + .build() + .expect("build request"); assert_eq!( - created.get("entity_id").and_then(|v| v.as_str()), - Some("ch_456") - ); - assert_eq!(probe.last_kind.lock().unwrap().as_deref(), Some("admin")); - assert_eq!( - probe.last_id.lock().unwrap().as_deref(), - Some("temperpaw-transport") + request + .headers() + .get("x-tenant-id") + .and_then(|value| value.to_str().ok()), + Some("default") ); + assert!(!request.headers().contains_key("x-temper-principal-kind")); + assert!(!request.headers().contains_key("x-temper-principal-id")); assert_eq!( - probe.last_tenant.lock().unwrap().as_deref(), - Some("default"), - ); - assert_eq!( - probe.last_auth.lock().unwrap().as_deref(), - None, - "loopback requests should bypass bearer auth and use internal admin headers", + request + .headers() + .get("authorization") + .and_then(|value| value.to_str().ok()), + Some("Bearer test-token") ); } @@ -849,27 +755,10 @@ mod tests { ); } - #[tokio::test] - async fn paw_api_client_includes_traceparent_from_active_span() { - let probe = HeaderProbe::default(); - let app = Router::new() - .route( - "/tdata/Channels('ch_trace')/Paw.Channel.ReceiveMessage", - post( - |State(probe): State, headers: HeaderMap| async move { - *probe.last_traceparent.lock().unwrap() = headers - .get("traceparent") - .and_then(|value| value.to_str().ok()) - .map(|value| value.to_string()); - (StatusCode::OK, Json(json!({"status":"ok"}))) - }, - ), - ) - .with_state(probe.clone()); - - let base_url = spawn_test_server(app).await; + #[test] + fn paw_api_client_includes_traceparent_from_active_span() { let client = PawApiClient::new(PawApiConfig { - base_url, + base_url: "http://127.0.0.1:3497".to_string(), tenant: "default".to_string(), api_key: None, }); @@ -882,37 +771,33 @@ mod tests { let _subscriber_guard = tracing::subscriber::set_default(subscriber); let span = tracing::info_span!("discord.receive"); - let expected_traceparent = { + let (expected_traceparent, request) = { let _span_guard = span.enter(); let span_context = tracing::Span::current() .context() .span() .span_context() .clone(); - let traceparent = format!( + let expected = format!( "00-{}-{}-01", span_context.trace_id(), span_context.span_id() ); - client - .dispatch_action( - "Channels", - "ch_trace", - "Paw.Channel.ReceiveMessage", - json!({ - "message_id": "msg_123", - "author_id": "user_456", - "thread_id": "thread_789", - "content": "hello", - }), + let request = client + .build_request( + reqwest::Method::POST, + "http://127.0.0.1:3497/tdata/Channels('ch_trace')/Paw.Channel.ReceiveMessage", ) - .await - .expect("dispatch should succeed"); - traceparent + .build() + .expect("build request"); + (expected, request) }; assert_eq!( - probe.last_traceparent.lock().unwrap().as_deref(), + request + .headers() + .get("traceparent") + .and_then(|value| value.to_str().ok()), Some(expected_traceparent.as_str()), "expected PawApiClient to propagate the active tracing span via traceparent", ); diff --git a/crates/temperpaw/src/auth.rs b/crates/temperpaw/src/auth.rs index e3d9d24bd..9ad193b4d 100644 --- a/crates/temperpaw/src/auth.rs +++ b/crates/temperpaw/src/auth.rs @@ -1,7 +1,6 @@ //! Authentication routes and middleware for the embedded dashboard. use std::collections::BTreeMap; -use std::net::SocketAddr; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -10,7 +9,7 @@ use argon2::Argon2; use argon2::password_hash::rand_core::OsRng; use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; use axum::body::Body; -use axum::extract::{ConnectInfo, State}; +use axum::extract::State; use axum::http::header::COOKIE; use axum::http::{HeaderMap, HeaderValue, Request, StatusCode}; use axum::middleware::Next; @@ -129,11 +128,10 @@ pub fn router(state: AuthState) -> Router { /// delegation, scope, and context: principal id/kind, `agent-role`, /// `agent-type`, `acting-for` (delegation), `principal-scopes`, `action-context`, /// and arbitrary `x-temper-attr-*` / `x-temper-ctx-*` / `x-temper-span-attr-*` -/// attributes. A remote client must never be able to set any of these; they are -/// honored only when injected server-side (cookie/bearer resolution) or -/// presented by a genuinely internal loopback caller. TemperPaw is the sole -/// network edge in front of the kernel, which does not itself strip these, so -/// the whole prefix is removed at the ingress edge for every external request. +/// attributes. A client must never be able to set any of these; they are honored +/// only when injected after credential resolution. TemperPaw is the sole network +/// edge in front of the kernel, which does not itself strip these, so the whole +/// prefix is removed at ingress for every request. const CLIENT_ASSERTABLE_IDENTITY_HEADER_PREFIX: &str = "x-temper-"; /// Additional exact header names outside the `x-temper-*` prefix that also carry @@ -148,16 +146,10 @@ pub async fn middleware( let path = request.uri().path().to_string(); let method = request.method().as_str().to_string(); - let from_internal_loopback = request_is_loopback(&request); - - // Ingress edge: a request that did not originate from a trusted in-process - // (loopback) caller must never carry client-asserted identity headers. Strip - // them before Cedar or the kernel bearer check can trust them. Identity for - // external requests is re-derived below only from a resolved credential - // (session cookie or bearer token). - if !from_internal_loopback { - strip_client_identity_headers(request.headers_mut()); - } + // Ingress edge: no network peer may carry client-asserted identity headers. + // Strip them before Cedar or the kernel bearer check can trust them. Identity + // is re-derived below only from a session or bearer credential. + strip_client_identity_headers(request.headers_mut()); if is_safe_setup_path_public_during_bootstrap(&state, &method, &path).await || is_public_path(request.method().as_str(), &path) @@ -176,21 +168,6 @@ pub async fn middleware( return next.run(request).await; } - // Internal WASM agent / transport calls arrive over loopback carrying - // principal headers but no Bearer token or session cookie. Only a genuinely - // loopback-origin request may self-assert its principal this way; external - // callers had these headers stripped above, so they cannot reach this branch - // with forged identity. Mark as pre-authenticated so Temper's - // bearer_auth_check passes through. - if from_internal_loopback - && request.headers().contains_key("x-temper-principal-kind") - && request.headers().contains_key("x-temper-principal-id") - { - ensure_tenant_header(request.headers_mut(), state.tenant()); - request.extensions_mut().insert(PreAuthenticatedRequest); - return next.run(request).await; - } - if path.starts_with("/dashboard") && !is_dashboard_public_path(&path) { return Redirect::temporary("/dashboard/login").into_response(); } @@ -198,20 +175,6 @@ pub async fn middleware( StatusCode::UNAUTHORIZED.into_response() } -/// True when the underlying TCP peer is loopback (`127.0.0.0/8` or `::1`), i.e. -/// the request came from another process inside this container rather than an -/// external client via the network edge. Derived from the real connection peer -/// address (`ConnectInfo`), never from a client-supplied forwarding header. -/// Absent connection info (e.g. in-process test transports) is treated as NOT -/// loopback — the safe default. -fn request_is_loopback(request: &Request) -> bool { - request - .extensions() - .get::>() - .map(|ConnectInfo(addr)| addr.ip().is_loopback()) - .unwrap_or(false) -} - /// Remove any client-asserted identity/tenant headers so downstream layers /// (Cedar, the kernel bearer check) cannot mistake them for a server-resolved /// principal. Strips the entire `x-temper-*` family plus the extra exact names. @@ -876,6 +839,9 @@ mod tests { .method("GET") .uri("/tdata/Agents") .header("cookie", cookie) + .header("x-temper-principal-kind", "agent") + .header("x-temper-principal-id", "attacker") + .header("x-tenant-id", "other-tenant") .body(Body::empty()) .unwrap(); let response = app.oneshot(request).await.unwrap(); @@ -1035,7 +1001,7 @@ mod tests { } #[tokio::test] - async fn external_forged_identity_family_is_stripped_before_downstream() { + async fn loopback_forged_identity_family_is_stripped_before_downstream() { // A public path passes through the middleware to the handler, so this // proves the full x-temper-* family (not just principal id/kind) is // removed for external callers before Cedar or a public handler sees it. @@ -1073,7 +1039,7 @@ mod tests { .unwrap(); request .extensions_mut() - .insert(ConnectInfo(SocketAddr::from(([203, 0, 113, 7], 5555)))); + .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 5555)))); let response = app.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); @@ -1087,17 +1053,9 @@ mod tests { } #[tokio::test] - async fn internal_loopback_self_asserted_principal_headers_are_admitted() { - async fn tdata_handler(headers: HeaderMap) -> impl IntoResponse { - let kind = headers - .get("x-temper-principal-kind") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default(); - let id = headers - .get("x-temper-principal-id") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default(); - (StatusCode::OK, Json(json!({ "kind": kind, "id": id }))) + async fn loopback_self_asserted_principal_headers_are_rejected() { + async fn tdata_handler() -> impl IntoResponse { + StatusCode::OK } let tempdir = tempfile::tempdir().unwrap(); @@ -1107,8 +1065,7 @@ mod tests { .merge(router(state.clone())) .layer(from_fn_with_state(state.clone(), middleware)); - // A genuinely internal caller: real TCP peer is loopback. Internal - // transport/setup/startup callers reach the server this way. + // Loopback is a routing property, not an identity credential. let mut request = Request::builder() .method("GET") .uri("/tdata/Agents") @@ -1121,11 +1078,57 @@ mod tests { .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 44321)))); let response = app.oneshot(request).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn loopback_bearer_credential_binds_kernel_identity() { + async fn tdata_handler(headers: HeaderMap) -> impl IntoResponse { + let value = |name: &str| { + headers + .get(name) + .and_then(|header| header.to_str().ok()) + .unwrap_or_default() + .to_string() + }; + Json(json!({ + "kind": value("x-temper-principal-kind"), + "id": value("x-temper-principal-id"), + "tenant": value("x-tenant-id"), + })) + } + + let tempdir = tempfile::tempdir().unwrap(); + let auth_state = AuthState::for_tests(tempdir.path()).await; + let mut platform_state = temper_platform::PlatformState::new(None); + platform_state.api_token = Some("platform-secret".to_string()); + let app = Router::new() + .route("/tdata/Agents", get(tdata_handler)) + .layer(from_fn_with_state( + platform_state, + temper_platform::bearer_auth::bearer_auth_check, + )) + .layer(from_fn_with_state(auth_state.clone(), middleware)); + + let mut request = Request::builder() + .method("GET") + .uri("/tdata/Agents") + .header("authorization", "Bearer platform-secret") + .header("x-temper-principal-kind", "agent") + .header("x-temper-principal-id", "attacker") + .header("x-tenant-id", "other-tenant") + .body(Body::empty()) + .unwrap(); + request + .extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 44321)))); + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); let payload: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(payload["kind"], "admin"); - assert_eq!(payload["id"], "temperpaw-transport"); + assert_eq!(payload["id"], "api-key-holder"); + assert_eq!(payload["tenant"], "default"); } } diff --git a/crates/temperpaw/src/setup.rs b/crates/temperpaw/src/setup.rs index 37ac75dd4..6147dc471 100644 --- a/crates/temperpaw/src/setup.rs +++ b/crates/temperpaw/src/setup.rs @@ -358,7 +358,6 @@ async fn resolve_paw_soul_entity( let agent_response: serde_json::Value = auth .apply(client.get(&agent_url)) .header("x-tenant-id", tenant) - .header("x-temper-principal-kind", "admin") .send() .await? .json() @@ -373,7 +372,6 @@ async fn resolve_paw_soul_entity( let soul_response: serde_json::Value = auth .apply(client.get(&soul_url)) .header("x-tenant-id", tenant) - .header("x-temper-principal-kind", "admin") .send() .await? .json() @@ -389,7 +387,6 @@ async fn resolve_paw_soul_entity( let soul_response: serde_json::Value = auth .apply(client.get(&soul_url)) .header("x-tenant-id", tenant) - .header("x-temper-principal-kind", "admin") .send() .await? .json() @@ -419,7 +416,6 @@ pub(crate) async fn load_paw_soul_content( let content = auth .apply(client.get(format!("{base}/tdata/Files('{file_id}')/$value"))) .header("x-tenant-id", tenant) - .header("x-temper-principal-kind", "admin") .send() .await? .text() @@ -489,7 +485,6 @@ pub(crate) async fn save_soul_to_temper( let resp = auth .apply(client.put(&upload_url)) .header("x-tenant-id", tenant) - .header("x-temper-principal-kind", "admin") .header("content-type", "text/markdown") .body(full_content) .send() diff --git a/crates/temperpaw/src/startup.rs b/crates/temperpaw/src/startup.rs index 7e9b4d28d..d4423ad91 100644 --- a/crates/temperpaw/src/startup.rs +++ b/crates/temperpaw/src/startup.rs @@ -578,16 +578,7 @@ fn spawn_runtime_server( listener: tokio::net::TcpListener, router: axum::Router, ) -> JoinHandle> { - // Serve with connection info so the auth middleware can read the real TCP - // peer address and distinguish genuinely internal (loopback) callers from - // external ones. See ADR-0066. - tokio::spawn(async move { - axum::serve( - listener, - router.into_make_service_with_connect_info::(), - ) - .await - }) + tokio::spawn(async move { axum::serve(listener, router).await }) } async fn startup_gate_middleware( @@ -3643,17 +3634,14 @@ fn normalize_legacy_workdir(current_workdir: &str) -> Option { None } -/// OData GET helper with tenant + admin auth headers. +/// OData GET helper with deployment tenant and bearer authentication. async fn odata_get( client: &reqwest::Client, url: &str, tenant: &str, api_key: &Option, ) -> Result { - let mut req = client - .get(url) - .header("x-tenant-id", tenant) - .header("x-temper-principal-kind", "admin"); + let mut req = client.get(url).header("x-tenant-id", tenant); if let Some(key) = api_key { req = req.header("authorization", format!("Bearer {key}")); } @@ -3666,7 +3654,7 @@ async fn odata_get( serde_json::from_str(&body).context("Failed to parse JSON response") } -/// OData POST helper with tenant + admin auth headers. +/// OData POST helper with deployment tenant and bearer authentication. async fn odata_post( client: &reqwest::Client, url: &str, @@ -3677,7 +3665,6 @@ async fn odata_post( let mut req = client .post(url) .header("x-tenant-id", tenant) - .header("x-temper-principal-kind", "admin") .header("content-type", "application/json") .json(&body); if let Some(key) = api_key { @@ -3703,7 +3690,6 @@ async fn odata_put_bytes( let mut req = client .put(url) .header("x-tenant-id", tenant) - .header("x-temper-principal-kind", "admin") .header("content-type", content_type) .body(body); if let Some(key) = api_key { @@ -3725,10 +3711,7 @@ async fn odata_get_text( tenant: &str, api_key: &Option, ) -> Result { - let mut req = client - .get(url) - .header("x-tenant-id", tenant) - .header("x-temper-principal-kind", "admin"); + let mut req = client.get(url).header("x-tenant-id", tenant); if let Some(key) = api_key { req = req.header("authorization", format!("Bearer {key}")); } diff --git a/docs/adrs/0066-reject-self-asserted-principal-headers.md b/docs/adrs/0066-reject-self-asserted-principal-headers.md index 8eac1c06d..ad9c56a37 100644 --- a/docs/adrs/0066-reject-self-asserted-principal-headers.md +++ b/docs/adrs/0066-reject-self-asserted-principal-headers.md @@ -6,97 +6,68 @@ Accepted. ## Context -The embedded dashboard/API auth middleware (`crates/temperpaw/src/auth.rs`) -treated **any** request carrying both `x-temper-principal-kind` and -`x-temper-principal-id` as a `PreAuthenticatedRequest` and passed the -client-supplied principal downstream — with no cookie, no bearer token, and no -check that the caller was actually internal. The code comment claimed the path -was for "internal WASM agent calls," but nothing verified the call was internal. - -The production server binds `0.0.0.0` and Railway deploys it as the network -edge with no header-stripping proxy in front of it. Client-supplied -`x-temper-*` headers therefore reached the app untouched. Any external caller -could self-assert an admin principal: - -``` -curl https:///tdata/ \ - -H 'x-temper-principal-kind: admin' \ - -H 'x-temper-principal-id: attacker' \ - -H 'x-tenant-id: default' -``` - -Cedar was the only remaining gate, and the client also controlled `x-tenant-id`. -This is the TemperPaw instance of the systemic **Class A** self-asserted-identity -bypass (epic ARN-165); the kernel counterpart was ARN-170 (temper PR #343). -Tracked as ARN-167. - -Legitimate internal callers already model "internal" as **loopback**: the -transport client (`crates/paw-transport`), setup, startup, and observer callers -all target `http://127.0.0.1:{port}` and only attach admin principal headers on -loopback URLs (`PawApiClient::uses_internal_loopback`). Remote agents/workers -authenticate with a Bearer token. So the property that distinguishes an internal -caller from an external one is the real TCP peer being loopback — something a -remote client cannot forge. +The embedded dashboard/API auth middleware treated any request carrying both +`x-temper-principal-kind` and `x-temper-principal-id` as a +`PreAuthenticatedRequest`. No cookie or bearer token was required, so an +Internet caller could self-assert an admin principal and supply the tenant and +Cedar attributes that the kernel later trusted. + +The first proposed repair stripped those headers only for non-loopback TCP +peers and retained header-only pre-authentication for loopback callers. That +does not establish identity. TemperPaw supports separate same-host processes, +including `paw-codex-worker`, and those processes run lower-trust tasks. Local +reverse proxies and server-side request paths can also terminate on loopback. +Any such caller could still select `admin` and bypass the bearer layer. + +TemperPaw already creates a platform API key during startup. Internal startup +helpers and transports can use that existing bearer credential; agent workers +use registry-issued credentials. A second internal secret or a network-location +identity mechanism is unnecessary. + +This is the TemperPaw instance of the systemic Class A self-asserted-identity +bypass tracked by ARN-167 under ARN-165. The kernel-side credential binding is +tracked separately by ARN-170. ## Decision -Identity for external requests is derived **only** from a resolved credential; -self-asserted identity headers are never trusted from a remote peer. Two changes -implement this at the ingress edge: - -1. **Strip client-asserted identity headers from every non-loopback request.** - Before any downstream logic (Cedar, the kernel `bearer_auth_check`) can read - them, the middleware removes the **entire `x-temper-*` header family** plus - `x-agent-id` and `x-tenant-id` from any request whose TCP peer is not - loopback. The whole prefix is stripped — not a hardcoded subset — because the - kernel's Cedar principal builder (`SecurityContext::from_headers` in - temper-authz) trusts far more than principal id/kind: it derives - `principal.role` from `x-temper-agent-role`, delegation from - `x-temper-acting-for`, scopes from `x-temper-principal-scopes`, arbitrary - principal attributes from `x-temper-attr-*`, and Cedar context attributes - (including `agentTypeVerified`) from `x-temper-ctx-*`. TemperPaw is the sole - network edge in front of the kernel and the kernel does not itself strip these, - so anything less than a full-family strip would leave a privilege-escalation - surface for any caller holding a valid low-privilege credential. The tenant and - principal are then re-derived server-side from the resolved credential (session - cookie injects the admin principal; bearer is resolved by the kernel), and the - deployment tenant is forced via `ensure_tenant_header`. - -2. **Honor the header-only "internal" path only for genuinely loopback peers.** - The branch that marks a request `PreAuthenticated` purely from the presence of - principal headers now additionally requires the request to originate from a - loopback peer. The peer is read from the real connection address - (`ConnectInfo`), never from a client-supplied forwarding header. - To make that address available, the runtime server is now served with - `into_make_service_with_connect_info::()`. When connection info is - absent (e.g. in-process test transports) the request is treated as **not** - loopback — the safe default. - -External callers therefore have no way to reach the pre-authenticated branch with -forged identity: their headers are stripped, and they are not loopback. Internal -loopback callers and cookie/bearer-authenticated callers are unaffected. +Identity at the TemperPaw HTTP edge is credential-derived for every network +peer, including loopback: + +1. The outer middleware removes the complete client-assertable identity family + from every request before public-path handling or authentication: + `x-temper-*`, `x-agent-id`, and `x-tenant-id`. +2. A valid TemperPaw session cookie may inject the authenticated dashboard + principal server-side and mark the request pre-authenticated. +3. A bearer credential passes to the kernel bearer middleware, which resolves + either a registered agent identity or the platform API-key administrator. + TemperPaw injects only its configured deployment tenant. +4. Requests with neither credential are rejected on protected paths regardless + of their source address. +5. Internal `PawApiClient` traffic uses its configured bearer token on loopback + exactly as it does remotely. It never synthesizes principal headers. + +Connection metadata may still support logging or rate controls, but it grants no +authentication or authorization capability. ## Alternatives considered -- **Internal shared secret / in-process marker instead of a loopback check.** - Topology-independent and forge-proof regardless of network layout, but it - requires threading a startup-generated secret through every internal caller, - including the separate `paw-codex-worker` process, for a larger change surface. - The loopback check reuses the convention internal callers already follow - (`uses_internal_loopback`) and needs no secret distribution. A shared secret - remains a viable hardening follow-up if the internal-call topology ever stops - being loopback. +- **Trust loopback principal headers.** Rejected because host/network placement + is not an identity boundary and same-host lower-privilege executors are a + supported topology. +- **Add a separate internal shared secret.** This would duplicate the existing + bearer credential path, add distribution and rotation state, and create a + second authentication implementation. +- **Use a Unix-domain socket for internal calls.** This can reduce exposure but + still requires peer/credential authorization and would add platform-specific + transport complexity. It remains optional defense in depth. ## Consequences -- Remote requests must present a session cookie or a Bearer token; self-asserted - `x-temper-*`/`x-tenant-id` headers from remote peers are ignored (stripped). -- In-container loopback callers (transport, setup, startup, observer) continue to - work unchanged because their real peer is `127.0.0.1`/`::1`. -- The runtime server now propagates connection info; this is additive and does - not change routing. -- **Residual risk:** the loopback check assumes external traffic never reaches the - app over a loopback peer. This holds on the current Railway topology (the app is - the edge; no same-container proxy forwards over loopback). If that topology - changes, or a `paw-codex-worker` runs remotely without a Bearer token, the - shared-secret hardening above should be adopted. +- Raw identity, delegation, scope, context, and tenant headers are untrusted at + the edge even when the TCP peer is loopback. +- Internal callers must possess the already-generated API key or a registered + scoped credential. No-key header-only compatibility is intentionally removed. +- Session and bearer flows remain the only authentication implementations. +- The runtime server no longer needs `ConnectInfo` solely for auth. +- A compromised local worker cannot become admin by changing request headers; + its effective identity is the one bound to its credential.