Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changelog/pr-182-admin-access-control.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
tidx: patch
---

Hardened view administration by failing closed for trusted CIDR checks, rejecting malformed CIDR configuration, hot-reloading active trusted CIDRs, and requiring an explicit admin mutation header.
105 changes: 79 additions & 26 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ mod views;
use std::collections::HashMap;
use std::convert::Infallible;
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::sync::{Arc, RwLock as StdRwLock};

use tokio::sync::RwLock;

use anyhow::{Result as AnyhowResult, anyhow};
use axum::{
Json, Router,
extract::{Query, State},
Expand Down Expand Up @@ -41,6 +42,7 @@ pub struct ChainClickHouseConfig {
}

pub type SharedClickHouseConfigs = Arc<RwLock<HashMap<u64, ChainClickHouseConfig>>>;
pub type SharedTrustedCidrs = Arc<StdRwLock<Vec<(IpAddr, u8)>>>;

#[derive(Clone)]
pub struct AppState {
Expand All @@ -54,7 +56,7 @@ pub struct AppState {
/// ClickHouse engines for OLAP queries (per chain)
pub clickhouse_engines: SharedClickHouseEngines,
/// Parsed trusted CIDRs for admin operations
pub trusted_cidrs: Arc<Vec<(IpAddr, u8)>>,
pub trusted_cidrs: SharedTrustedCidrs,
}

impl AppState {
Expand All @@ -70,28 +72,41 @@ impl AppState {

/// Check if an IP address is in the trusted CIDRs
pub fn is_trusted_ip(&self, addr: &SocketAddr) -> bool {
if self.trusted_cidrs.is_empty() {
return true;
}
let ip = addr.ip();
self.trusted_cidrs
.iter()
.any(|(network, prefix)| ip_in_cidr(&ip, network, *prefix))
.read()
.map(|cidrs| {
cidrs
.iter()
.any(|(network, prefix)| ip_in_cidr(&ip, network, *prefix))
})
.unwrap_or(false)
}
}

/// Parse CIDR strings into (network, prefix_len) tuples
pub fn parse_cidrs(cidrs: &[String]) -> Vec<(IpAddr, u8)> {
pub fn parse_cidrs(cidrs: &[String]) -> AnyhowResult<Vec<(IpAddr, u8)>> {
cidrs
.iter()
.filter_map(|cidr| {
let parts: Vec<&str> = cidr.split('/').collect();
if parts.len() != 2 {
return None;
.map(|cidr| {
let (ip, prefix) = cidr
.split_once('/')
.ok_or_else(|| anyhow!("Invalid CIDR '{cidr}': missing prefix"))?;
let ip: IpAddr = ip
.parse()
.map_err(|e| anyhow!("Invalid CIDR '{cidr}': invalid IP address: {e}"))?;
let prefix: u8 = prefix
.parse()
.map_err(|e| anyhow!("Invalid CIDR '{cidr}': invalid prefix: {e}"))?;
match ip {
IpAddr::V4(_) if prefix > 32 => {
Err(anyhow!("Invalid CIDR '{cidr}': IPv4 prefix exceeds 32"))
}
IpAddr::V6(_) if prefix > 128 => {
Err(anyhow!("Invalid CIDR '{cidr}': IPv6 prefix exceeds 128"))
}
_ => Ok((ip, prefix)),
}
let ip: IpAddr = parts[0].parse().ok()?;
let prefix: u8 = parts[1].parse().ok()?;
Some((ip, prefix))
})
.collect()
}
Expand Down Expand Up @@ -131,7 +146,7 @@ pub fn router(
pools: HashMap<u64, Pool>,
default_chain_id: u64,
broadcaster: Arc<Broadcaster>,
) -> Router<()> {
) -> AnyhowResult<Router<()>> {
router_with_options(
pools,
default_chain_id,
Expand All @@ -147,8 +162,8 @@ pub fn router_with_options(
broadcaster: Arc<Broadcaster>,
clickhouse_configs: HashMap<u64, ChainClickHouseConfig>,
http_config: &HttpConfig,
) -> Router<()> {
let trusted_cidrs = Arc::new(parse_cidrs(&http_config.trusted_cidrs));
) -> AnyhowResult<Router<()>> {
let trusted_cidrs = Arc::new(StdRwLock::new(parse_cidrs(&http_config.trusted_cidrs)?));

let state = AppState {
pools: Arc::new(RwLock::new(pools)),
Expand All @@ -159,7 +174,7 @@ pub fn router_with_options(
trusted_cidrs,
};

build_router(state)
Ok(build_router(state))
}

pub fn router_shared(
Expand All @@ -168,10 +183,8 @@ pub fn router_shared(
broadcaster: Arc<Broadcaster>,
clickhouse_configs: SharedClickHouseConfigs,
clickhouse_engines: SharedClickHouseEngines,
trusted_cidrs: Vec<String>,
trusted_cidrs: SharedTrustedCidrs,
) -> Router<()> {
let trusted_cidrs = Arc::new(parse_cidrs(&trusted_cidrs));

let state = AppState {
pools,
default_chain_id,
Expand All @@ -186,7 +199,7 @@ pub fn router_shared(

fn build_router(state: AppState) -> Router<()> {
let cors = CorsLayer::new()
.allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
.allow_methods([Method::GET, Method::OPTIONS])
.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION])
.allow_origin(tower_http::cors::Any);

Expand Down Expand Up @@ -707,7 +720,7 @@ mod tests {
"10.0.0.0/8".to_string(),
"192.168.1.0/24".to_string(),
];
let parsed = parse_cidrs(&cidrs);
let parsed = parse_cidrs(&cidrs).unwrap();
assert_eq!(parsed.len(), 3);
assert_eq!(parsed[0], ("100.64.0.0".parse().unwrap(), 10));
assert_eq!(parsed[1], ("10.0.0.0".parse().unwrap(), 8));
Expand All @@ -721,8 +734,48 @@ mod tests {
"100.64.0.0".to_string(), // Missing prefix
"100.64.0.0/abc".to_string(), // Invalid prefix
];
let parsed = parse_cidrs(&cidrs);
assert_eq!(parsed.len(), 0);
assert!(parse_cidrs(&cidrs).is_err());
assert!(parse_cidrs(&["100.64.0.0/33".to_string()]).is_err());
assert!(parse_cidrs(&["fd7a:115c:a1e0::/129".to_string()]).is_err());
}

#[test]
fn test_router_with_options_rejects_invalid_trusted_cidr() {
let http_config = HttpConfig {
trusted_cidrs: vec!["100.64.0.0/33".to_string()],
..Default::default()
};

let result = router_with_options(
HashMap::new(),
0,
Arc::new(Broadcaster::new()),
HashMap::new(),
&http_config,
);

assert!(result.is_err());
}

#[test]
fn test_trusted_ip_fails_closed_when_empty() {
let state = AppState {
pools: Arc::new(RwLock::new(HashMap::new())),
default_chain_id: 0,
broadcaster: Arc::new(Broadcaster::new()),
clickhouse_configs: Arc::new(RwLock::new(HashMap::new())),
clickhouse_engines: Arc::new(RwLock::new(HashMap::new())),
trusted_cidrs: Arc::new(std::sync::RwLock::new(Vec::new())),
};
let addr: SocketAddr = "127.0.0.1:8080".parse().unwrap();
assert!(!state.is_trusted_ip(&addr));
}

#[test]
fn test_http_config_default_trusts_only_loopback() {
let parsed = parse_cidrs(&HttpConfig::default().trusted_cidrs).unwrap();
assert!(parsed.contains(&("127.0.0.1".parse().unwrap(), 32)));
assert!(parsed.contains(&("::1".parse().unwrap(), 128)));
}

#[test]
Expand Down
77 changes: 65 additions & 12 deletions src/api/views.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,40 @@
use axum::{
Json,
extract::{ConnectInfo, Path, Query, State},
http::HeaderMap,
};
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;

use super::{ApiError, AppState};
use crate::query::EventSignature;

const ADMIN_MUTATION_HEADER: &str = "x-tidx-admin";

fn require_admin_mutation(
headers: &HeaderMap,
state: &AppState,
addr: &SocketAddr,
) -> Result<(), ApiError> {
if !state.is_trusted_ip(addr) {
return Err(ApiError::Forbidden(
"Mutations only allowed from trusted IPs".to_string(),
));
}

if headers
.get(ADMIN_MUTATION_HEADER)
.and_then(|value| value.to_str().ok())
!= Some("1")
{
return Err(ApiError::Forbidden(
"Missing admin mutation header".to_string(),
));
}

Ok(())
}

/// Validate view name (alphanumeric + underscore only)
fn is_valid_view_name(name: &str) -> bool {
is_valid_identifier(name)
Expand Down Expand Up @@ -170,14 +197,10 @@ pub struct CreateViewResponse {
pub async fn create_view(
State(state): State<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(req): Json<CreateViewRequest>,
) -> Result<Json<CreateViewResponse>, ApiError> {
// Check trusted IP access
if !state.is_trusted_ip(&addr) {
return Err(ApiError::Forbidden(
"Mutations only allowed from trusted IPs".to_string(),
));
}
require_admin_mutation(&headers, &state, &addr)?;

// Validate view name
if !is_valid_view_name(&req.name) {
Expand Down Expand Up @@ -320,15 +343,11 @@ pub struct DeleteViewResponse {
pub async fn delete_view(
State(state): State<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Path(name): Path<String>,
Query(params): Query<ChainQuery>,
) -> Result<Json<DeleteViewResponse>, ApiError> {
// Check trusted IP access
if !state.is_trusted_ip(&addr) {
return Err(ApiError::Forbidden(
"Mutations only allowed from trusted IPs".to_string(),
));
}
require_admin_mutation(&headers, &state, &addr)?;

// Validate view name
if !is_valid_view_name(&name) {
Expand Down Expand Up @@ -446,7 +465,12 @@ pub async fn get_view(
#[cfg(test)]
mod tests {
use super::*;
use crate::broadcast::Broadcaster;
use insta::assert_snapshot;
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::{Arc, RwLock as StdRwLock};
use tokio::sync::RwLock;

#[test]
fn test_valid_view_name() {
Expand All @@ -460,6 +484,35 @@ mod tests {
assert!(!is_valid_view_name("my view")); // Has space
}

fn test_state_with_trusted_localhost() -> AppState {
let trusted_cidrs = vec![("127.0.0.1".parse::<IpAddr>().unwrap(), 32)];
AppState {
pools: Arc::new(RwLock::new(HashMap::new())),
default_chain_id: 0,
broadcaster: Arc::new(Broadcaster::new()),
clickhouse_configs: Arc::new(RwLock::new(HashMap::new())),
clickhouse_engines: Arc::new(RwLock::new(HashMap::new())),
trusted_cidrs: Arc::new(StdRwLock::new(trusted_cidrs)),
}
}

#[test]
fn test_requires_admin_mutation_header() {
let state = test_state_with_trusted_localhost();
let addr: SocketAddr = "127.0.0.1:8080".parse().unwrap();
let headers = HeaderMap::new();
assert!(require_admin_mutation(&headers, &state, &addr).is_err());
}

#[test]
fn test_accepts_admin_mutation_header_from_trusted_ip() {
let state = test_state_with_trusted_localhost();
let addr: SocketAddr = "127.0.0.1:8080".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert(ADMIN_MUTATION_HEADER, "1".parse().unwrap());
assert!(require_admin_mutation(&headers, &state, &addr).is_ok());
}

// ========================================================================
// Helper to generate full SQL from signature + user query
// ========================================================================
Expand Down
7 changes: 4 additions & 3 deletions src/cli/up.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,8 @@ pub async fn run(args: Args) -> Result<()> {
let (chain_tx, mut chain_rx) = tokio::sync::mpsc::channel::<NewChainEvent>(16);

if !args.no_watch {
let watcher = ConfigWatcher::new(args.config.clone(), &config, chain_tx);
let watcher = ConfigWatcher::new(args.config.clone(), &config, chain_tx)?;
let trusted_cidrs = watcher.trusted_cidrs();
watcher.start()?;

if config.http.enabled && default_chain_id != 0 {
Expand All @@ -136,7 +137,7 @@ pub async fn run(args: Args) -> Result<()> {
broadcaster.clone(),
Arc::clone(&clickhouse_configs),
Arc::clone(&clickhouse_engines),
config.http.trusted_cidrs.clone(),
trusted_cidrs,
);

info!(addr = %addr, "Starting HTTP API server (hot-reload enabled)");
Expand Down Expand Up @@ -201,7 +202,7 @@ pub async fn run(args: Args) -> Result<()> {
broadcaster.clone(),
clickhouse_configs.read().await.clone(),
&config.http,
);
)?;

info!(addr = %addr, "Starting HTTP API server");

Expand Down
8 changes: 6 additions & 2 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ pub struct HttpConfig {
pub bind: String,

/// Trusted CIDRs for admin operations (e.g., `100.64.0.0/10` for Tailscale)
#[serde(default)]
#[serde(default = "default_trusted_cidrs")]
pub trusted_cidrs: Vec<String>,
}

Expand All @@ -45,7 +45,7 @@ impl Default for HttpConfig {
enabled: true,
port: 8080,
bind: "0.0.0.0".to_string(),
trusted_cidrs: Vec::new(),
trusted_cidrs: default_trusted_cidrs(),
}
}
}
Expand Down Expand Up @@ -82,6 +82,10 @@ fn default_bind() -> String {
"0.0.0.0".to_string()
}

fn default_trusted_cidrs() -> Vec<String> {
vec!["127.0.0.1/32".to_string(), "::1/128".to_string()]
}

fn default_metrics_port() -> u16 {
9090
}
Expand Down
Loading
Loading