Allow dynamic tables from app_generator in db_api

- Added table_exists_in_database() to check if table exists in PostgreSQL
- Updated validate_table_name() to allow valid identifiers (not just whitelist)
- Added validate_table_name_with_conn() for full validation with DB check
- Added is_table_allowed_with_conn() for handlers to verify table existence
- Updated list_records_handler and count_records_handler to use dynamic check
- Uses parameterized query for table existence check (SQL injection safe)
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2026-01-02 18:20:04 -03:00
parent 762620f7a9
commit bbbb9e190f
2 changed files with 137 additions and 5 deletions

View file

@ -6,7 +6,7 @@ use crate::core::shared::sanitize_identifier;
use crate::core::urls::ApiUrls; use crate::core::urls::ApiUrls;
use crate::security::error_sanitizer::log_and_sanitize; use crate::security::error_sanitizer::log_and_sanitize;
use crate::security::sql_guard::{ use crate::security::sql_guard::{
build_safe_count_query, build_safe_select_query, validate_table_name, build_safe_count_query, build_safe_select_query, is_table_allowed_with_conn, validate_table_name,
}; };
use axum::{ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
@ -126,7 +126,7 @@ pub async fn list_records_handler(
let limit = params.limit.unwrap_or(20).min(100); let limit = params.limit.unwrap_or(20).min(100);
let offset = params.offset.unwrap_or(0); let offset = params.offset.unwrap_or(0);
// Validate table name against whitelist // Validate table name (basic check - no SQL injection)
if let Err(e) = validate_table_name(&table_name) { if let Err(e) = validate_table_name(&table_name) {
warn!("Invalid table name attempted: {} - {}", table_name, e); warn!("Invalid table name attempted: {} - {}", table_name, e);
return ( return (
@ -150,6 +150,16 @@ pub async fn list_records_handler(
} }
}; };
// Check if table actually exists in database (supports dynamic tables from app_generator)
if !is_table_allowed_with_conn(&mut conn, &table_name) {
warn!("Table not found in database: {}", table_name);
return (
StatusCode::NOT_FOUND,
Json(json!({ "error": format!("Table '{}' not found", table_name) })),
)
.into_response();
}
// Check table-level read access // Check table-level read access
let access_info = let access_info =
match check_table_access(&mut conn, &table_name, &user_roles, AccessType::Read) { match check_table_access(&mut conn, &table_name, &user_roles, AccessType::Read) {
@ -631,6 +641,16 @@ pub async fn count_records_handler(
let table_name = sanitize_identifier(&table); let table_name = sanitize_identifier(&table);
let user_roles = user_roles_from_headers(&headers); let user_roles = user_roles_from_headers(&headers);
// Validate table name (basic check - no SQL injection)
if let Err(e) = validate_table_name(&table_name) {
warn!("Invalid table name attempted: {} - {}", table_name, e);
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "Invalid table name" })),
)
.into_response();
}
let mut conn = match state.conn.get() { let mut conn = match state.conn.get() {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
@ -639,6 +659,16 @@ pub async fn count_records_handler(
} }
}; };
// Check if table actually exists in database (supports dynamic tables from app_generator)
if !is_table_allowed_with_conn(&mut conn, &table_name) {
warn!("Table not found in database: {}", table_name);
return (
StatusCode::NOT_FOUND,
Json(json!({ "error": format!("Table '{}' not found", table_name) })),
)
.into_response();
}
// Check table-level read access (count requires read permission) // Check table-level read access (count requires read permission)
if let Err(e) = check_table_access(&mut conn, &table_name, &user_roles, AccessType::Read) { if let Err(e) = check_table_access(&mut conn, &table_name, &user_roles, AccessType::Read) {
return (StatusCode::FORBIDDEN, Json(json!({ "error": e }))).into_response(); return (StatusCode::FORBIDDEN, Json(json!({ "error": e }))).into_response();

View file

@ -1,7 +1,8 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::sync::LazyLock; use std::sync::LazyLock;
static ALLOWED_TABLES: LazyLock<HashSet<&'static str>> = LazyLock::new(|| { /// System tables that are always allowed (core application tables)
static SYSTEM_TABLES: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
HashSet::from([ HashSet::from([
"automations", "automations",
"bots", "bots",
@ -33,6 +34,35 @@ static ALLOWED_TABLES: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
]) ])
}); });
/// Check if a table exists in the database
/// This allows dynamically created tables (from app generator) to be accessed
pub fn table_exists_in_database(conn: &mut diesel::PgConnection, table_name: &str) -> bool {
use diesel::prelude::*;
use diesel::sql_query;
use diesel::sql_types::Text;
#[derive(diesel::QueryableByName)]
struct TableExists {
#[diesel(sql_type = diesel::sql_types::Bool)]
exists: bool,
}
let result: Result<TableExists, _> = sql_query(
"SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = $1
) as exists"
)
.bind::<Text, _>(table_name)
.get_result(conn);
match result {
Ok(r) => r.exists,
Err(_) => false,
}
}
static ALLOWED_ORDER_COLUMNS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| { static ALLOWED_ORDER_COLUMNS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
HashSet::from([ HashSet::from([
"id", "id",
@ -75,10 +105,59 @@ impl std::fmt::Display for SqlGuardError {
impl std::error::Error for SqlGuardError {} impl std::error::Error for SqlGuardError {}
/// Validate table name - checks system tables whitelist only
/// For full validation including dynamic tables, use validate_table_name_with_conn
pub fn validate_table_name(table: &str) -> Result<&str, SqlGuardError> { pub fn validate_table_name(table: &str) -> Result<&str, SqlGuardError> {
let sanitized = sanitize_identifier(table); let sanitized = sanitize_identifier(table);
if ALLOWED_TABLES.contains(sanitized.as_str()) { // Check basic identifier validity (no SQL injection)
if sanitized.is_empty() || sanitized.len() > 63 {
return Err(SqlGuardError::InvalidTableName(table.to_string()));
}
// Check for dangerous patterns
if sanitized.contains(';') || sanitized.contains("--") || sanitized.contains("/*") {
return Err(SqlGuardError::PotentialInjection(table.to_string()));
}
// System tables are always allowed
if SYSTEM_TABLES.contains(sanitized.as_str()) {
return Ok(table);
}
// For non-system tables, we allow them if they look safe
// The actual existence check should be done with validate_table_name_with_conn
// This allows dynamically created tables from app_generator to work
if sanitized.chars().all(|c| c.is_alphanumeric() || c == '_') {
Ok(table)
} else {
Err(SqlGuardError::InvalidTableName(table.to_string()))
}
}
/// Validate table name with database connection - checks if table actually exists
pub fn validate_table_name_with_conn<'a>(
conn: &mut diesel::PgConnection,
table: &'a str,
) -> Result<&'a str, SqlGuardError> {
let sanitized = sanitize_identifier(table);
// First do basic validation
if sanitized.is_empty() || sanitized.len() > 63 {
return Err(SqlGuardError::InvalidTableName(table.to_string()));
}
if sanitized.contains(';') || sanitized.contains("--") || sanitized.contains("/*") {
return Err(SqlGuardError::PotentialInjection(table.to_string()));
}
// System tables are always allowed
if SYSTEM_TABLES.contains(sanitized.as_str()) {
return Ok(table);
}
// Check if table exists in database (for dynamically created tables)
if table_exists_in_database(conn, &sanitized) {
Ok(table) Ok(table)
} else { } else {
Err(SqlGuardError::InvalidTableName(table.to_string())) Err(SqlGuardError::InvalidTableName(table.to_string()))
@ -229,9 +308,32 @@ pub fn register_dynamic_table(table_name: &'static str) {
log::info!("Dynamic table registration requested for: {}", table_name); log::info!("Dynamic table registration requested for: {}", table_name);
} }
/// Check if table is in the system tables whitelist
pub fn is_system_table(table: &str) -> bool {
let sanitized = sanitize_identifier(table);
SYSTEM_TABLES.contains(sanitized.as_str())
}
/// Check if table is allowed (system table or valid identifier)
/// For full check including database existence, use is_table_allowed_with_conn
pub fn is_table_allowed(table: &str) -> bool { pub fn is_table_allowed(table: &str) -> bool {
let sanitized = sanitize_identifier(table); let sanitized = sanitize_identifier(table);
ALLOWED_TABLES.contains(sanitized.as_str()) if SYSTEM_TABLES.contains(sanitized.as_str()) {
return true;
}
// Allow valid identifiers (actual existence checked elsewhere)
!sanitized.is_empty()
&& sanitized.len() <= 63
&& sanitized.chars().all(|c| c.is_alphanumeric() || c == '_')
}
/// Check if table is allowed with database connection
pub fn is_table_allowed_with_conn(conn: &mut diesel::PgConnection, table: &str) -> bool {
let sanitized = sanitize_identifier(table);
if SYSTEM_TABLES.contains(sanitized.as_str()) {
return true;
}
table_exists_in_database(conn, &sanitized)
} }
#[cfg(test)] #[cfg(test)]