use std::net::IpAddr;
use actix_web::{
body::{EitherBody, MessageBody},
cookie::{time::Duration as CookieDuration, Cookie, SameSite},
dev::{ServiceRequest, ServiceResponse},
http::header,
middleware::Next,
web, HttpRequest, HttpResponse, ResponseError,
};
use chrono::{SecondsFormat, Utc};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::{
app_state::{AppState, ConfigUpdateEffects},
error::AppError,
};
use bamboo_infrastructure::{AccessControlConfig, Config};
#[derive(Serialize)]
pub struct AccessStatusResponse {
pub password_enabled: bool,
pub local_bypass: bool,
pub requires_password: bool,
}
#[derive(Debug, Deserialize)]
pub struct VerifyPasswordRequest {
pub password: String,
}
#[derive(Serialize)]
pub struct VerifyPasswordResponse {
pub success: bool,
}
#[derive(Debug, Deserialize)]
pub struct UpdatePasswordRequest {
#[serde(default)]
pub current_password: String,
#[serde(default)]
pub new_password: String,
}
#[derive(Serialize)]
pub struct UpdatePasswordResponse {
pub success: bool,
pub password_enabled: bool,
}
const ACCESS_VERIFIED_COOKIE_NAME: &str = "bamboo_access_verified";
const ACCESS_VERIFIED_COOKIE_MAX_AGE_SECS: i64 = 60 * 60 * 12;
const ACCESS_VERIFIED_COOKIE_VERSION: &str = "v1";
fn normalize_ip(ip: &str) -> &str {
let ip = ip.trim();
ip.strip_prefix("::ffff:").unwrap_or(ip)
}
fn split_host_and_port(value: &str) -> &str {
let candidate = value.trim();
if candidate.is_empty() {
return candidate;
}
let without_brackets = candidate
.strip_prefix('[')
.and_then(|v| v.strip_suffix(']'))
.unwrap_or(candidate);
if without_brackets.parse::<IpAddr>().is_ok() {
return without_brackets;
}
without_brackets
.split(':')
.next()
.unwrap_or(without_brackets)
.trim()
}
fn is_local_host(host: &str) -> bool {
let normalized = split_host_and_port(host)
.trim()
.trim_end_matches('.')
.to_lowercase();
if normalized.is_empty() {
return false;
}
if normalized == "localhost" || normalized.ends_with(".local") {
return true;
}
let normalized = normalize_ip(&normalized);
match normalized.parse::<IpAddr>() {
Ok(IpAddr::V4(v4)) => {
v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified()
}
Ok(IpAddr::V6(v6)) => {
v6.is_loopback()
|| v6.is_unique_local()
|| v6.is_unicast_link_local()
|| v6.is_unspecified()
}
Err(_) => false,
}
}
fn request_host_candidates(req: &HttpRequest) -> Vec<String> {
let mut candidates = Vec::new();
for header_name in [
header::HOST,
header::HeaderName::from_static("x-forwarded-host"),
header::HeaderName::from_static("x-original-host"),
] {
if let Some(value) = req
.headers()
.get(&header_name)
.and_then(|v| v.to_str().ok())
{
for part in value.split(',') {
let host = part.trim();
if !host.is_empty() {
candidates.push(host.to_string());
}
}
}
}
if let Some(uri_host) = req.uri().host() {
let host = uri_host.trim();
if !host.is_empty() {
candidates.push(host.to_string());
}
}
candidates
}
fn is_local_request(req: &HttpRequest) -> bool {
let host_candidates = request_host_candidates(req);
if !host_candidates.is_empty() {
return host_candidates.iter().all(|host| is_local_host(host));
}
if let Some(peer) = req.peer_addr() {
return is_local_host(&peer.ip().to_string());
}
let conn = req.connection_info();
for candidate in [conn.realip_remote_addr(), conn.peer_addr()]
.into_iter()
.flatten()
{
if is_local_host(candidate) {
return true;
}
}
false
}
fn compute_password_hash(password: &str, salt_hex: &str) -> Option<String> {
let salt = hex::decode(salt_hex).ok()?;
let mut hasher = Sha256::new();
hasher.update(&salt);
hasher.update(password.as_bytes());
Some(hex::encode(hasher.finalize()))
}
fn verify_password(config: &Config, password: &str) -> bool {
let Some(access) = config.access_control.as_ref() else {
return false;
};
if !access.password_enabled {
return false;
}
let (Some(hash), Some(salt)) = (
access.password_hash.as_deref(),
access.password_salt.as_deref(),
) else {
return false;
};
compute_password_hash(password, salt)
.map(|computed| computed == hash)
.unwrap_or(false)
}
fn access_verification_cookie_value(config: &Config) -> Option<String> {
let access = config.access_control.as_ref()?;
if !access.password_enabled {
return None;
}
let hash = access.password_hash.as_deref()?.trim();
let salt = access.password_salt.as_deref()?.trim();
if hash.is_empty() || salt.is_empty() {
return None;
}
let mut hasher = Sha256::new();
hasher.update(ACCESS_VERIFIED_COOKIE_VERSION.as_bytes());
hasher.update(b":");
hasher.update(hash.as_bytes());
hasher.update(b":");
hasher.update(salt.as_bytes());
Some(format!(
"{}:{}",
ACCESS_VERIFIED_COOKIE_VERSION,
hex::encode(hasher.finalize())
))
}
fn request_has_verified_access_cookie(req: &HttpRequest, config: &Config) -> bool {
let expected = match access_verification_cookie_value(config) {
Some(value) => value,
None => return false,
};
req.cookie(ACCESS_VERIFIED_COOKIE_NAME)
.map(|cookie| cookie.value() == expected)
.unwrap_or(false)
}
fn build_access_verified_cookie(config: &Config, secure: bool) -> Option<Cookie<'static>> {
let value = access_verification_cookie_value(config)?;
Some(
Cookie::build(ACCESS_VERIFIED_COOKIE_NAME, value)
.path("/")
.http_only(true)
.same_site(SameSite::Lax)
.secure(secure)
.max_age(CookieDuration::seconds(ACCESS_VERIFIED_COOKIE_MAX_AGE_SECS))
.finish(),
)
}
fn is_public_access_route(path: &str) -> bool {
matches!(
path,
"/api/v1/health" | "/v1/bamboo/access/status" | "/v1/bamboo/access/verify"
)
}
pub async fn enforce_access_password_middleware<B: MessageBody + 'static>(
req: ServiceRequest,
next: Next<B>,
) -> Result<ServiceResponse<EitherBody<B>>, actix_web::Error> {
let path = req.path().to_string();
if is_public_access_route(&path) {
return next
.call(req)
.await
.map(ServiceResponse::map_into_left_body);
}
let app_state = match req.app_data::<web::Data<AppState>>() {
Some(state) => state.clone(),
None => {
return next
.call(req)
.await
.map(ServiceResponse::map_into_left_body)
}
};
let config = app_state.config.read().await.clone();
let access_status = build_access_status(&config, req.request());
if !access_status.requires_password
|| request_has_verified_access_cookie(req.request(), &config)
{
return next
.call(req)
.await
.map(ServiceResponse::map_into_left_body);
}
let response = AppError::Unauthorized("access password verification required".to_string())
.error_response()
.map_into_right_body();
Ok(req.into_response(response))
}
fn build_access_status(config: &Config, req: &HttpRequest) -> AccessStatusResponse {
let password_enabled = config
.access_control
.as_ref()
.map(|access| {
access.password_enabled
&& access
.password_hash
.as_deref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false)
&& access
.password_salt
.as_deref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false)
})
.unwrap_or(false);
let local_bypass = is_local_request(req);
AccessStatusResponse {
password_enabled,
local_bypass,
requires_password: password_enabled && !local_bypass,
}
}
pub async fn get_access_status(
req: HttpRequest,
app_state: web::Data<AppState>,
) -> Result<HttpResponse, AppError> {
let config = app_state.config.read().await.clone();
Ok(HttpResponse::Ok().json(build_access_status(&config, &req)))
}
pub async fn verify_access_password(
req: HttpRequest,
payload: web::Json<VerifyPasswordRequest>,
app_state: web::Data<AppState>,
) -> Result<HttpResponse, AppError> {
let password = payload.password.trim();
if password.is_empty() {
return Err(AppError::BadRequest("password is required".to_string()));
}
let config = app_state.config.read().await.clone();
if !verify_password(&config, password) {
return Err(AppError::Unauthorized("invalid password".to_string()));
}
let secure = req.connection_info().scheme().eq_ignore_ascii_case("https");
let cookie = build_access_verified_cookie(&config, secure)
.ok_or_else(|| AppError::Unauthorized("access password is not enabled".to_string()))?;
Ok(HttpResponse::Ok()
.cookie(cookie)
.json(VerifyPasswordResponse { success: true }))
}
pub async fn update_access_password(
req: HttpRequest,
app_state: web::Data<AppState>,
payload: web::Json<UpdatePasswordRequest>,
) -> Result<HttpResponse, AppError> {
let local_bypass = is_local_request(&req);
let new_password = payload.new_password.trim();
if new_password.is_empty() {
return Err(AppError::BadRequest("new_password is required".to_string()));
}
let current_config = app_state.config.read().await.clone();
let password_already_enabled = current_config
.access_control
.as_ref()
.map(|access| access.password_enabled)
.unwrap_or(false);
if password_already_enabled && !local_bypass {
let current_password = payload.current_password.trim();
if current_password.is_empty() {
return Err(AppError::Unauthorized(
"current_password is required".to_string(),
));
}
if !verify_password(¤t_config, current_password) {
return Err(AppError::Unauthorized(
"invalid current password".to_string(),
));
}
}
let mut salt_bytes = [0_u8; 16];
rand::thread_rng().fill_bytes(&mut salt_bytes);
let salt_hex = hex::encode(salt_bytes);
let password_hash = compute_password_hash(new_password, &salt_hex).ok_or_else(|| {
AppError::InternalError(anyhow::anyhow!("failed to compute password hash"))
})?;
let updated_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
app_state
.update_config(
move |config| {
config.access_control = Some(AccessControlConfig {
password_enabled: true,
password_hash: Some(password_hash.clone()),
password_salt: Some(salt_hex.clone()),
updated_at: Some(updated_at.clone()),
});
Ok(())
},
ConfigUpdateEffects::default(),
)
.await?;
Ok(HttpResponse::Ok().json(UpdatePasswordResponse {
success: true,
password_enabled: true,
}))
}
#[cfg(test)]
mod tests {
use super::*;
use actix_web::test::TestRequest;
#[test]
fn loopback_request_is_local() {
let req = TestRequest::default()
.peer_addr("127.0.0.1:12345".parse().unwrap())
.insert_header((header::HOST, "localhost:9562"))
.to_http_request();
assert!(is_local_request(&req));
}
#[test]
fn private_lan_host_is_local() {
let req = TestRequest::default()
.insert_header((header::HOST, "192.168.0.10:9562"))
.to_http_request();
assert!(is_local_request(&req));
}
#[test]
fn remote_host_is_not_local_even_when_peer_is_loopback() {
let req = TestRequest::default()
.peer_addr("127.0.0.1:12345".parse().unwrap())
.insert_header((header::HOST, "bamboo.example.com"))
.to_http_request();
assert!(!is_local_request(&req));
}
#[test]
fn password_hash_roundtrip_verifies() {
let salt_hex = hex::encode([1_u8; 16]);
let hash = compute_password_hash("secret", &salt_hex).unwrap();
let config = Config {
access_control: Some(AccessControlConfig {
password_enabled: true,
password_hash: Some(hash),
password_salt: Some(salt_hex),
updated_at: None,
}),
..Config::default()
};
assert!(verify_password(&config, "secret"));
assert!(!verify_password(&config, "wrong"));
}
}