Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ mod interface;
mod k8s_auth;
mod listener;
mod mapping;
mod oslo_middleware;
mod policy;
mod resource;
mod revoke;
Expand Down Expand Up @@ -108,6 +109,7 @@ pub use interface::*;
pub use k8s_auth::*;
pub use listener::*;
pub use mapping::*;
pub use oslo_middleware::*;
pub use policy::*;
pub use resource::*;
pub use revoke::*;
Expand Down Expand Up @@ -202,6 +204,10 @@ pub struct Config {
#[serde(default)]
pub mapping: MappingProvider,

/// `[oslo_middleware]` configuration (proxy header parsing).
#[serde(default)]
pub oslo_middleware: OsloMiddleware,

/// Server listener configuration for the internal interface.
#[serde(rename = "interface_internal", default)]
pub interface_internal: Option<InternalInterface>,
Expand Down
94 changes: 94 additions & 0 deletions crates/config/src/oslo_middleware.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
//! # `[oslo_middleware]` configuration
//!
//! Mirrors the subset of upstream Python Keystone's `[oslo_middleware]`
//! section that is relevant to client-address capture.

use serde::Deserialize;

use crate::common::csv;

/// `[oslo_middleware]` section.
#[derive(Debug, Deserialize, Clone, Default)]
pub struct OsloMiddleware {
/// Whether to parse proxy forwarding headers (`Forwarded` per RFC 7239,
/// falling back to `X-Forwarded-For`) on the public interface to recover
/// the originating client address, overwriting the raw TCP peer captured
/// in `ConnectInfo<SocketAddr>`.
///
/// The name and default (**off**) match upstream oslo.middleware's
/// `HTTPProxyToWSGI`. Even when enabled, a header is only honoured when the
/// immediate TCP peer matches [`trusted_proxies`](Self::trusted_proxies), so
/// a client reaching the listener directly cannot spoof its address.
#[serde(default)]
pub enable_proxy_headers_parsing: bool,

/// CIDR blocks of reverse proxies trusted to set the forwarding headers
/// (e.g. `10.0.0.0/8, 192.168.0.0/16`). The client address is recovered
/// only when the immediate TCP peer falls within one of these ranges; the
/// effective client is then the rightmost address in the forwarding chain
/// that is not itself a trusted proxy. Empty (the default) means no proxy
/// is trusted, so the raw peer address is always used even when parsing is
/// enabled.
#[serde(default, deserialize_with = "csv")]
pub trusted_proxies: Vec<String>,
}

#[cfg(test)]
mod tests {
use config::{Config, File, FileFormat};

use super::*;

#[test]
fn defaults_to_disabled() {
let sot = OsloMiddleware::default();
assert!(!sot.enable_proxy_headers_parsing);
}

#[test]
fn parses_enabled_flag_from_ini() {
let c = Config::builder()
.add_source(File::from_str(
"enable_proxy_headers_parsing = true",
FileFormat::Ini,
))
.build()
.unwrap();
let sot: OsloMiddleware = c.try_deserialize().unwrap();
assert!(sot.enable_proxy_headers_parsing);
}

#[test]
fn defaults_to_no_trusted_proxies() {
assert!(OsloMiddleware::default().trusted_proxies.is_empty());
}

#[test]
fn parses_trusted_proxies_csv_from_ini() {
let c = Config::builder()
.add_source(File::from_str(
"trusted_proxies = 10.0.0.0/8,192.168.0.0/16",
FileFormat::Ini,
))
.build()
.unwrap();
let sot: OsloMiddleware = c.try_deserialize().unwrap();
assert_eq!(
sot.trusted_proxies,
vec!["10.0.0.0/8".to_string(), "192.168.0.0/16".to_string()]
);
}
}
1 change: 1 addition & 0 deletions crates/core/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
pub mod api_key_auth;
pub mod auth;
pub mod common;
pub mod forwarded;
pub mod v3;
pub mod v4;

Expand Down
118 changes: 1 addition & 117 deletions crates/core/src/api/api_key_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ use openstack_keystone_core_types::mapping::resolution::IdentitySource;
use openstack_keystone_core_types::mapping::virtual_user::MatchResult;

use crate::api::KeystoneApiError;
use crate::api::forwarded::resolve_client_ip;
use crate::api_key::{crypto, token};
use crate::auth::{ExecutionContext, ValidatedSecurityContext};
use crate::keystone::ServiceState;
Expand Down Expand Up @@ -214,51 +215,6 @@ fn spawn_last_used_update(state: &ServiceState, resource: &ApiClientResource, no
});
}

/// Compute the effective client IP using the rightmost-non-trusted-proxy
/// algorithm (ADR 0021 §3 Step 2, §6.E, Invariant 4): append the raw TCP
/// peer to the right of the `X-Forwarded-For` chain, then walk right to
/// left, returning the first address not in `trusted_proxies`. If the raw
/// TCP peer itself is not trusted, it is used directly without consulting
/// XFF at all.
fn resolve_client_ip(
headers: &axum::http::HeaderMap,
peer_ip: Option<IpAddr>,
trusted_proxies: &[String],
) -> Option<IpAddr> {
let trusted: Vec<IpNet> = trusted_proxies
.iter()
.filter_map(|c| c.parse::<IpNet>().ok())
.collect();

let is_trusted = |ip: &IpAddr| trusted.iter().any(|net| net.contains(ip));

let peer = peer_ip?;
if !is_trusted(&peer) {
return Some(peer);
}

let xff_chain: Vec<IpAddr> = headers
.get(axum::http::header::HeaderName::from_static(
"x-forwarded-for",
))
.and_then(|h| h.to_str().ok())
.map(|h| {
h.split(',')
.filter_map(|s| s.trim().parse::<IpAddr>().ok())
.collect()
})
.unwrap_or_default();

let mut chain = xff_chain;
chain.push(peer);

chain
.into_iter()
.rev()
.find(|ip| !is_trusted(ip))
.or(Some(peer))
}

/// Whether `client_ip` satisfies the key's `allowed_ips` CIDR allowlist.
/// `None` (missing field) means no restriction applies (ADR 0021 Invariant
/// 5); a missing field and `Some(vec![])` are treated identically.
Expand Down Expand Up @@ -394,83 +350,11 @@ async fn hydrate_ephemeral_context(
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderMap;

fn headers_with_xff(xff: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::HeaderName::from_static("x-forwarded-for"),
xff.parse().unwrap(),
);
headers
}

fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}

// ---------------------------------------------------------------------
// resolve_client_ip (ADR 0021 §3 Step 2, §6.E, Invariant 4)
// ---------------------------------------------------------------------

#[test]
fn untrusted_peer_ignores_xff_entirely() {
// Peer itself is not a trusted proxy: XFF must not be consulted at
// all, even if present (prevents spoofing via an untrusted hop).
let headers = headers_with_xff("1.2.3.4");
let peer = Some(ip("203.0.113.5"));
let trusted = vec!["10.0.0.0/8".to_string()];
assert_eq!(
resolve_client_ip(&headers, peer, &trusted),
Some(ip("203.0.113.5"))
);
}

#[test]
fn trusted_peer_walks_xff_rightmost_non_trusted() {
// Chain (left to right): 1.2.3.4 (attacker-controlled), 10.0.0.5
// (trusted intermediate hop), peer 10.0.0.1 (trusted, terminal
// proxy). Effective IP must be 10.0.0.5's predecessor scanning
// right-to-left: append peer, walk right-to-left, first non-trusted.
let headers = headers_with_xff("1.2.3.4, 10.0.0.5");
let peer = Some(ip("10.0.0.1"));
let trusted = vec!["10.0.0.0/8".to_string()];
assert_eq!(
resolve_client_ip(&headers, peer, &trusted),
Some(ip("1.2.3.4"))
);
}

#[test]
fn trusted_peer_all_hops_trusted_falls_back_to_peer() {
let headers = headers_with_xff("10.0.0.9");
let peer = Some(ip("10.0.0.1"));
let trusted = vec!["10.0.0.0/8".to_string()];
assert_eq!(
resolve_client_ip(&headers, peer, &trusted),
Some(ip("10.0.0.1"))
);
}

#[test]
fn leftmost_xff_entry_is_never_trusted_blindly() {
// Regression guard for the leftmost-take vulnerability (ADR 0021 F2):
// an attacker prepending a spoofed IP as the leftmost XFF entry must
// not be accepted just because it's present.
let headers = headers_with_xff("203.0.113.99, 1.2.3.4, 10.0.0.5");
let peer = Some(ip("10.0.0.1"));
let trusted = vec!["10.0.0.0/8".to_string()];
let effective = resolve_client_ip(&headers, peer, &trusted);
assert_ne!(effective, Some(ip("203.0.113.99")));
assert_eq!(effective, Some(ip("1.2.3.4")));
}

#[test]
fn no_peer_ip_resolves_to_none() {
let headers = HeaderMap::new();
assert_eq!(resolve_client_ip(&headers, None, &[]), None);
}

// ---------------------------------------------------------------------
// ip_allowed (ADR 0021 Invariant 5)
// ---------------------------------------------------------------------
Expand Down
Loading
Loading