Skip to content

Commit 684b9d1

Browse files
authored
Merge pull request #179 from jamals86/feature/vector-search
Add trusted-proxy support; Docker & CI updates
2 parents a51b931 + 8c09ba7 commit 684b9d1

28 files changed

Lines changed: 1007 additions & 274 deletions

.github/workflows/release.yml

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1319,19 +1319,19 @@ jobs:
13191319
tags: |
13201320
${{ steps.vars.outputs.docker_repo }}:${{ needs.read_version.outputs.version }}-amd64
13211321
1322-
- name: Test Docker image (linux/amd64)
1323-
continue-on-error: true
1322+
- name: Test Docker startup (linux/amd64)
13241323
shell: bash
13251324
run: |
13261325
set -euo pipefail
13271326
export KALAMDB_JWT_SECRET="$(openssl rand -base64 32)"
1327+
export DOCKER_PLATFORM="linux/amd64"
13281328
13291329
# Pull the just-built image
13301330
docker pull ${{ steps.vars.outputs.docker_repo }}:${{ needs.read_version.outputs.version }}-amd64
13311331
1332-
# Run smoke test
1333-
chmod +x docker/build/test-docker-image.sh
1334-
./docker/build/test-docker-image.sh ${{ steps.vars.outputs.docker_repo }}:${{ needs.read_version.outputs.version }}-amd64
1332+
# Require at least a clean startup before publishing multi-arch tags
1333+
chmod +x docker/build/test-docker-startup.sh
1334+
./docker/build/test-docker-startup.sh ${{ steps.vars.outputs.docker_repo }}:${{ needs.read_version.outputs.version }}-amd64
13351335
13361336
- name: Build and push Docker image (linux/arm64)
13371337
uses: docker/build-push-action@v6
@@ -1380,6 +1380,14 @@ jobs:
13801380
13811381
echo "✅ Multi-arch manifest created for ${REPO}:${VERSION}, ${REPO}:${VERSION_COMMIT_TAG}, and ${REPO}:latest"
13821382
1383+
- name: Update Docker Hub repository README
1384+
uses: peter-evans/dockerhub-description@v4
1385+
with:
1386+
username: ${{ secrets.DOCKERHUB_USERNAME }}
1387+
password: ${{ secrets.DOCKERHUB_TOKEN }}
1388+
repository: ${{ steps.vars.outputs.docker_repo }}
1389+
readme-filepath: ./docker/REPO-README.md
1390+
13831391
# ═══════════════════════════════════════════════════════════════════════════
13841392
# INTEGRATION TESTS - Smoke + TypeScript SDK + Dart SDK against Docker image
13851393
# ═══════════════════════════════════════════════════════════════════════════

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ sha2 = "0.10"
178178
storekey = "0.11"
179179
moka = { version = "0.12.13", features = ["future", "sync"] }
180180
ntest = "0.9.5"
181+
ipnet = "2.11.0"
181182
wasm-bindgen = { version = "0.2.114" }
182183
wasm-bindgen-futures = { version = "0.4.64" }
183184
js-sys = { version = "0.3.91" }

backend/crates/kalamdb-auth/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ tracing = { workspace = true }
5151

5252
# Async runtime
5353
tokio = { workspace = true }
54+
ipnet = { workspace = true }
5455

5556
# Time handling
5657
chrono = { workspace = true }

backend/crates/kalamdb-auth/src/helpers/ip_extractor.rs

Lines changed: 153 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,16 @@
33
//! This module provides secure extraction of client IP addresses from HTTP requests,
44
//! with protection against header spoofing attacks that attempt to bypass localhost checks.
55
6+
use actix_web::http::header::{HeaderMap, HeaderValue};
67
use actix_web::HttpRequest;
8+
use ipnet::IpNet;
79
use kalamdb_commons::models::ConnectionInfo;
810
use log::warn;
11+
use once_cell::sync::Lazy;
12+
use std::net::IpAddr;
13+
use std::sync::RwLock;
14+
15+
static TRUSTED_PROXY_RANGES: Lazy<RwLock<Vec<IpNet>>> = Lazy::new(|| RwLock::new(Vec::new()));
916

1017
/// Extract client IP address with security checks against header spoofing
1118
///
@@ -34,36 +41,80 @@ use log::warn;
3441
/// println!("Client IP: {:?}", client_ip);
3542
/// }
3643
/// ```
44+
pub fn init_trusted_proxy_ranges(entries: &[String]) -> anyhow::Result<()> {
45+
let parsed = kalamdb_configs::parse_trusted_proxy_entries(entries)?;
46+
*TRUSTED_PROXY_RANGES
47+
.write()
48+
.expect("trusted proxy ranges lock poisoned") = parsed;
49+
Ok(())
50+
}
51+
52+
pub fn extract_client_ip_addr_secure(peer_addr: Option<IpAddr>, headers: &HeaderMap) -> Option<IpAddr> {
53+
let trusted_proxy_ranges = TRUSTED_PROXY_RANGES
54+
.read()
55+
.expect("trusted proxy ranges lock poisoned");
56+
extract_client_ip_addr_with_trusted_ranges(peer_addr, headers, &trusted_proxy_ranges)
57+
}
58+
3759
pub fn extract_client_ip_secure(req: &HttpRequest) -> ConnectionInfo {
38-
let peer_addr = req.peer_addr().map(|addr| addr.ip());
39-
40-
// Trust X-Forwarded-For only when the direct peer is loopback (trusted local reverse proxy).
41-
if peer_addr.is_some_and(|ip| ip.is_loopback()) {
42-
if let Some(forwarded_for) = req.headers().get("X-Forwarded-For") {
43-
if let Ok(header_value) = forwarded_for.to_str() {
44-
// Take first IP in comma-separated list (original client)
45-
let first_ip = header_value.split(',').next().unwrap_or("").trim();
46-
47-
// Security check: Reject localhost values in X-Forwarded-For
48-
// This prevents bypass attempts like: X-Forwarded-For: 127.0.0.1
49-
if is_localhost_address(first_ip) {
50-
warn!(
51-
"Security: Rejected localhost value in trusted X-Forwarded-For header: '{}'. Using peer_addr instead.",
52-
first_ip
53-
);
54-
} else if !first_ip.is_empty() {
55-
return ConnectionInfo::new(Some(first_ip.to_string()));
56-
}
57-
}
60+
extract_client_ip_addr_secure(req.peer_addr().map(|addr| addr.ip()), req.headers())
61+
.map(|ip| ConnectionInfo::new(Some(ip.to_string())))
62+
.unwrap_or_else(|| ConnectionInfo::new(None))
63+
}
64+
65+
fn extract_client_ip_addr_with_trusted_ranges(
66+
peer_addr: Option<IpAddr>,
67+
headers: &HeaderMap,
68+
trusted_proxy_ranges: &[IpNet],
69+
) -> Option<IpAddr> {
70+
if peer_addr.is_some_and(|ip| is_trusted_proxy_peer(ip, trusted_proxy_ranges)) {
71+
if let Some(ip) = extract_proxy_header_ip(headers.get("X-Forwarded-For"), true, "X-Forwarded-For") {
72+
return Some(ip);
73+
}
74+
75+
if let Some(ip) = extract_proxy_header_ip(headers.get("X-Real-IP"), false, "X-Real-IP") {
76+
return Some(ip);
5877
}
59-
} else if req.headers().contains_key("X-Forwarded-For") {
60-
warn!("Security: Ignoring X-Forwarded-For from non-loopback peer {:?}", peer_addr);
78+
} else if headers.contains_key("X-Forwarded-For") || headers.contains_key("X-Real-IP") {
79+
warn!(
80+
"Security: Ignoring proxy headers from untrusted peer {:?}",
81+
peer_addr
82+
);
6183
}
6284

63-
// Fallback to peer address (direct TCP connection)
6485
peer_addr
65-
.map(|ip| ConnectionInfo::new(Some(ip.to_string())))
66-
.unwrap_or_else(|| ConnectionInfo::new(None))
86+
}
87+
88+
fn is_trusted_proxy_peer(peer_addr: IpAddr, trusted_proxy_ranges: &[IpNet]) -> bool {
89+
peer_addr.is_loopback() || trusted_proxy_ranges.iter().any(|range| range.contains(&peer_addr))
90+
}
91+
92+
fn extract_proxy_header_ip(
93+
header: Option<&HeaderValue>,
94+
first_csv_value: bool,
95+
header_name: &str,
96+
) -> Option<IpAddr> {
97+
let header_value = header?.to_str().ok()?;
98+
let candidate = if first_csv_value {
99+
header_value.split(',').next().unwrap_or("").trim()
100+
} else {
101+
header_value.trim()
102+
};
103+
104+
if candidate.is_empty() {
105+
return None;
106+
}
107+
108+
if is_localhost_address(candidate) {
109+
warn!(
110+
"Security: Rejected localhost value in trusted {} header: '{}'. Using peer_addr instead.",
111+
header_name,
112+
candidate
113+
);
114+
return None;
115+
}
116+
117+
candidate.parse::<IpAddr>().ok()
67118
}
68119

69120
/// Check if an IP address string represents localhost
@@ -94,6 +145,7 @@ pub fn is_localhost_address(ip: &str) -> bool {
94145
#[cfg(test)]
95146
mod tests {
96147
use super::*;
148+
use actix_web::http::header::{HeaderMap, HeaderName, HeaderValue};
97149

98150
#[test]
99151
fn test_is_localhost_address() {
@@ -138,4 +190,80 @@ mod tests {
138190
);
139191
}
140192
}
193+
194+
#[test]
195+
fn test_loopback_proxy_headers_are_trusted() {
196+
let mut headers = HeaderMap::new();
197+
headers.insert(
198+
HeaderName::from_static("x-forwarded-for"),
199+
HeaderValue::from_static("203.0.113.8, 10.0.0.1"),
200+
);
201+
202+
let ip = extract_client_ip_addr_with_trusted_ranges(
203+
Some("127.0.0.1".parse().unwrap()),
204+
&headers,
205+
&[],
206+
);
207+
208+
assert_eq!(ip, Some("203.0.113.8".parse().unwrap()));
209+
}
210+
211+
#[test]
212+
fn test_trusted_proxy_range_allows_forwarded_headers() {
213+
let mut headers = HeaderMap::new();
214+
headers.insert(
215+
HeaderName::from_static("x-forwarded-for"),
216+
HeaderValue::from_static("203.0.113.8"),
217+
);
218+
let trusted_ranges = kalamdb_configs::parse_trusted_proxy_entries(&[
219+
"10.0.0.0/8".to_string(),
220+
])
221+
.unwrap();
222+
223+
let ip = extract_client_ip_addr_with_trusted_ranges(
224+
Some("10.0.1.9".parse().unwrap()),
225+
&headers,
226+
&trusted_ranges,
227+
);
228+
229+
assert_eq!(ip, Some("203.0.113.8".parse().unwrap()));
230+
}
231+
232+
#[test]
233+
fn test_untrusted_proxy_headers_are_ignored() {
234+
let mut headers = HeaderMap::new();
235+
headers.insert(
236+
HeaderName::from_static("x-forwarded-for"),
237+
HeaderValue::from_static("203.0.113.8"),
238+
);
239+
240+
let ip = extract_client_ip_addr_with_trusted_ranges(
241+
Some("10.0.1.9".parse().unwrap()),
242+
&headers,
243+
&[],
244+
);
245+
246+
assert_eq!(ip, Some("10.0.1.9".parse().unwrap()));
247+
}
248+
249+
#[test]
250+
fn test_localhost_spoofing_is_rejected_even_for_trusted_proxy() {
251+
let mut headers = HeaderMap::new();
252+
headers.insert(
253+
HeaderName::from_static("x-forwarded-for"),
254+
HeaderValue::from_static("127.0.0.1"),
255+
);
256+
let trusted_ranges = kalamdb_configs::parse_trusted_proxy_entries(&[
257+
"10.0.0.0/8".to_string(),
258+
])
259+
.unwrap();
260+
261+
let ip = extract_client_ip_addr_with_trusted_ranges(
262+
Some("10.0.1.9".parse().unwrap()),
263+
&headers,
264+
&trusted_ranges,
265+
);
266+
267+
assert_eq!(ip, Some("10.0.1.9".parse().unwrap()));
268+
}
141269
}

backend/crates/kalamdb-auth/src/lib.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ pub use helpers::extractor::{AuthExtractError, AuthSessionExtractor};
2323
// Re-export unified session type from kalamdb-session
2424
pub use kalamdb_session::AuthSession;
2525
// Re-export items needed by extractor
26-
pub use helpers::ip_extractor::{extract_client_ip_secure, is_localhost_address};
26+
pub use helpers::ip_extractor::{
27+
extract_client_ip_addr_secure, extract_client_ip_secure, init_trusted_proxy_ranges,
28+
is_localhost_address,
29+
};
2730
pub use models::impersonation::{ImpersonationContext, ImpersonationOrigin};
2831
pub use providers::jwt_auth::{
2932
create_and_sign_refresh_token, create_and_sign_token, generate_jwt_token, refresh_jwt_token,

backend/crates/kalamdb-configs/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ toml = { workspace = true }
1414
anyhow = { workspace = true }
1515
num_cpus = { workspace = true }
1616
openraft = { workspace = true }
17+
ipnet = { workspace = true }
1718

1819
[lib]
1920
name = "kalamdb_configs"

backend/crates/kalamdb-configs/src/config/loader.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use super::types::ServerConfig;
2+
use super::trusted_proxies::parse_trusted_proxy_entries;
23
use crate::file_helpers::normalize_dir_path;
34
use std::fs;
45
use std::path::Path;
@@ -106,6 +107,13 @@ impl ServerConfig {
106107
return Err(anyhow::anyhow!("cleanup_job_schedule cannot be empty"));
107108
}
108109

110+
parse_trusted_proxy_entries(&self.security.trusted_proxy_ranges).map_err(|error| {
111+
anyhow::anyhow!(
112+
"Invalid security.trusted_proxy_ranges configuration: {}",
113+
error
114+
)
115+
})?;
116+
109117
Ok(())
110118
}
111119
}
@@ -133,4 +141,11 @@ mod tests {
133141
config.logging.level = "invalid".to_string();
134142
assert!(config.validate().is_err());
135143
}
144+
145+
#[test]
146+
fn test_invalid_trusted_proxy_ranges() {
147+
let mut config = ServerConfig::default();
148+
config.security.trusted_proxy_ranges = vec!["nope".to_string()];
149+
assert!(config.validate().is_err());
150+
}
136151
}

backend/crates/kalamdb-configs/src/config/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ pub mod defaults;
33
pub mod loader;
44
#[path = "override.rs"]
55
pub mod overrides;
6+
pub mod trusted_proxies;
67
pub mod types;
78

9+
pub use trusted_proxies::*;
810
pub use cluster::*;
911
pub use types::*;

backend/crates/kalamdb-configs/src/config/override.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ impl ServerConfig {
3838
/// - KALAMDB_COOKIE_SECURE: Override auth.cookie_secure
3939
/// - KALAMDB_ALLOW_REMOTE_SETUP: Override auth.allow_remote_setup
4040
/// - KALAMDB_AUTH_AUTO_CREATE_USERS_FROM_PROVIDER: Override auth.auto_create_users_from_provider
41+
/// - KALAMDB_SECURITY_TRUSTED_PROXY_RANGES: Override security.trusted_proxy_ranges
4142
/// - KALAMDB_RATE_LIMIT_AUTH_REQUESTS_PER_IP_PER_SEC: Override rate_limit.max_auth_requests_per_ip_per_sec
4243
/// - KALAMDB_WEBSOCKET_CLIENT_TIMEOUT_SECS: Override websocket.client_timeout_secs
4344
/// - KALAMDB_WEBSOCKET_AUTH_TIMEOUT_SECS: Override websocket.auth_timeout_secs
@@ -146,6 +147,17 @@ impl ServerConfig {
146147
val.to_lowercase() == "true" || val == "1" || val.to_lowercase() == "yes";
147148
}
148149

150+
// Trusted proxy ranges used for X-Forwarded-For / X-Real-IP trust.
151+
if let Ok(val) = env::var("KALAMDB_SECURITY_TRUSTED_PROXY_RANGES")
152+
.or_else(|_| env::var("KALAMDB_TRUSTED_PROXY_RANGES"))
153+
{
154+
self.security.trusted_proxy_ranges = val
155+
.split(',')
156+
.map(|entry| entry.trim().to_string())
157+
.filter(|entry| !entry.is_empty())
158+
.collect();
159+
}
160+
149161
// Auth rate limit per IP
150162
if let Ok(val) = env::var("KALAMDB_RATE_LIMIT_AUTH_REQUESTS_PER_IP_PER_SEC") {
151163
self.rate_limit.max_auth_requests_per_ip_per_sec = val.parse().map_err(|_| {
@@ -355,4 +367,23 @@ mod tests {
355367

356368
env::remove_var("KALAMDB_LOG_LEVEL");
357369
}
370+
371+
#[test]
372+
fn test_env_override_trusted_proxy_ranges() {
373+
let _guard = acquire_env_lock();
374+
env::set_var(
375+
"KALAMDB_SECURITY_TRUSTED_PROXY_RANGES",
376+
"10.0.1.9,192.168.0.0/24",
377+
);
378+
379+
let mut config = ServerConfig::default();
380+
config.apply_env_overrides().unwrap();
381+
382+
assert_eq!(
383+
config.security.trusted_proxy_ranges,
384+
vec!["10.0.1.9".to_string(), "192.168.0.0/24".to_string()]
385+
);
386+
387+
env::remove_var("KALAMDB_SECURITY_TRUSTED_PROXY_RANGES");
388+
}
358389
}

0 commit comments

Comments
 (0)