This commit is contained in:
Andras Schmelczer 2026-07-03 18:01:10 +01:00
parent c2070693fb
commit 909e241907
55 changed files with 594 additions and 223 deletions

View file

@ -1,6 +1,6 @@
use axum::body::Body;
use axum::extract::{ConnectInfo, Request};
use axum::http::StatusCode;
use axum::http::{HeaderMap, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use metrics::{counter, gauge, histogram};
@ -119,7 +119,7 @@ fn normalize_path(path: &str) -> String {
if path.starts_with("/assets/") {
return "/assets/:file".to_string();
}
// Known application routes and API endpoints keep as-is
// Known application routes and API endpoints: keep as-is
if path.starts_with("/api/")
|| matches!(
path,
@ -147,11 +147,20 @@ fn normalize_path(path: &str) -> String {
/// Handler for the /metrics endpoint. Only accepts requests from peers on the
/// same private network (loopback, RFC1918, or IPv6 unique/link-local).
///
/// The TCP peer is always the reverse proxy (a private container IP), so the
/// real client IP is taken from the proxy-set `X-Real-IP` / `X-Forwarded-For`
/// headers when present; a public client reaching this endpoint through the
/// public domain is therefore rejected. Direct in-network connections (e.g. the
/// Prometheus scraper hitting the container directly) carry no such header and
/// fall back to the trusted TCP peer address.
pub async fn metrics_handler(
handle: PrometheusHandle,
headers: HeaderMap,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
) -> Response {
if !is_same_network(peer.ip()) {
let client_ip = client_ip_from_headers(&headers).unwrap_or_else(|| peer.ip());
if !is_same_network(client_ip) {
return StatusCode::FORBIDDEN.into_response();
}
@ -167,6 +176,27 @@ pub async fn metrics_handler(
}
}
/// Resolve the real client IP from trusted reverse-proxy headers. nginx sets
/// `X-Real-IP` to the post-`real_ip` client address and overwrites any
/// client-supplied value, so when present it is authoritative; the left-most
/// `X-Forwarded-For` hop is used as a fallback. Returns `None` when neither
/// header is set, so a direct in-network connection uses its TCP peer address.
fn client_ip_from_headers(headers: &HeaderMap) -> Option<IpAddr> {
if let Some(value) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) {
if let Ok(ip) = value.trim().parse::<IpAddr>() {
return Some(ip);
}
}
if let Some(value) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
if let Some(first) = value.split(',').next() {
if let Ok(ip) = first.trim().parse::<IpAddr>() {
return Some(ip);
}
}
}
None
}
fn is_same_network(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => v4.is_loopback() || v4.is_private() || v4.is_link_local(),
@ -216,3 +246,34 @@ pub fn record_data_stats(property_count: usize, poi_count: usize, postcode_count
gauge!("data_pois_loaded").set(poi_count as f64);
gauge!("data_postcodes_loaded").set(postcode_count as f64);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn x_real_ip_is_authoritative_and_public_ip_is_rejected() {
let mut headers = HeaderMap::new();
headers.insert("x-real-ip", "203.0.113.7".parse().unwrap());
let ip = client_ip_from_headers(&headers).expect("resolves from x-real-ip");
assert_eq!(ip, "203.0.113.7".parse::<IpAddr>().unwrap());
assert!(!is_same_network(ip), "a public client must be rejected");
}
#[test]
fn missing_proxy_headers_fall_back_to_peer() {
// No X-Real-IP / X-Forwarded-For → caller uses the TCP peer (e.g. an
// in-network Prometheus scraper hitting the container directly).
assert_eq!(client_ip_from_headers(&HeaderMap::new()), None);
}
#[test]
fn forwarded_for_uses_first_hop() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", "198.51.100.4, 10.0.0.1".parse().unwrap());
assert_eq!(
client_ip_from_headers(&headers),
Some("198.51.100.4".parse::<IpAddr>().unwrap())
);
}
}