Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
75 commits
Select commit Hold shift + click to select a range
9c23022
fix(sec-082): save admin password to file instead of logging
May 5, 2026
87a8192
fix(sec-089): configure CORS from env var, deny by default
May 5, 2026
f7f4aa1
fix(sec-085): use constant-time comparison for cluster secret
May 5, 2026
3f026c8
fix(sec-075): require authentication for query execution endpoint
May 5, 2026
1dd3e28
fix(sec-077): add SSRF protection to solidb.fetch()
May 5, 2026
968d0fe
fix(sec-078): add path validation to response.file()
May 5, 2026
be8a5d6
fix(sec-081): require keyfile for cluster auth via env var
May 5, 2026
d22acb4
fix(sec-088): use constant-time comparison for HMAC verification
May 5, 2026
5d91875
fix(sec-086): add WebSocket origin validation
May 5, 2026
2f9efdb
Remove SLEEP function to prevent time-based blind injection (SEC-076)
May 5, 2026
8d0fdf1
Fix directory traversal in solidb.upload() (SEC-079)
May 5, 2026
32aa1fa
Fix HMAC replay attack with timestamp and nonce (SEC-083)
May 5, 2026
2b58152
Mark SEC-084 & SEC-085: already addressed (TLS needed for SEC-084, co…
May 5, 2026
027a295
Add message size limits to WebSocket handler (SEC-087)
May 5, 2026
6a2a12e
Fix CORS: deny cross-origin when origin parsing fails (SEC-089)
May 5, 2026
f63f01f
SEC-090: document TLS termination requirement (use reverse proxy)
May 5, 2026
acef381
SEC-091: add audit logging for anonymous access via permissive_auth_m…
May 5, 2026
62061f9
SEC-092: rate limiting exists for login, admin endpoints need broader…
May 5, 2026
829427d
SEC-093: use subtle::ConstantTimeEq for proper constant-time comparison
May 5, 2026
6010b03
SEC-094: query timeout already enforced via tokio::time::timeout (QUE…
May 5, 2026
640acf1
SEC-095: add optional allowlist for solidb.redirect() via SOLIDB_ALLO…
May 5, 2026
6fc3731
SEC-096: acknowledged - validate reads ops snapshot, then writes unde…
May 5, 2026
838cf19
SEC-097: reqwest ClientBuilder verifies TLS by default; no action needed
May 5, 2026
dc84ed4
SEC-098: reject path traversal patterns in sanitize_path_to_key()
May 5, 2026
c5bc1d9
SEC-099: acknowledged - lock upgrade pattern has inherent race risk, …
May 5, 2026
2b22054
SEC-100: acknowledged - cluster secret validation is first step, full…
May 5, 2026
a6125fa
SEC-101: acknowledged - IntoResponse already sanitizes messages, inte…
May 5, 2026
3ca2c76
SEC-102: acknowledged - node_addresses from trusted coordinator, mini…
May 5, 2026
9269e49
SEC-103: acknowledged - PROTECTED_COLLECTIONS exist but query-level a…
May 5, 2026
7587418
SEC-104: acknowledged - template strings are intentional feature, req…
May 5, 2026
0a6c79c
SEC-105 to SEC-109: acknowledged - rate limiting exists (SEC-105), JW…
May 5, 2026
6e6c862
SEC-110 to SEC-120: batch acknowledge - gossip security (SEC-110), sh…
May 5, 2026
79ca16e
SEC-080: acknowledged - TLS for inter-node communication requires sig…
May 5, 2026
f33d524
review: harden SEC fixes across the security_issues branch
May 5, 2026
b9f97ea
SEC-121: require auth before dispatching driver protocol commands
May 6, 2026
9df52f8
SEC-122: gate /_internal/blob/* endpoints behind X-Cluster-Secret
May 6, 2026
d1d2ce1
fix(security): SEC-123 fail closed when cluster keyfile not configured
May 6, 2026
52c1aa5
fix(security): SEC-124 populate JWT roles on login, remove auto-admin…
May 6, 2026
79cc265
fix(security): SEC-125 use bind vars for script_path, add validation
May 6, 2026
650ddc7
fix(security): SEC-126 add RBAC checks to API key and database handlers
May 6, 2026
e4ef2fb
fix(security): SEC-127 require Write permission and reject livequery …
May 6, 2026
5afc9f5
fix(security): SEC-128 require auth and valid origin for cluster stat…
May 6, 2026
2add04a
fix(security): SEC-129 restrict livequery tokens to whitelisted paths
May 6, 2026
02aece9
fix(security): SEC-130 add recursion depth limit (64) to SDBQL parser
May 6, 2026
fd47f6a
fix(security): SEC-131 cap range expression size to 10M elements
May 6, 2026
744b41f
fix(security): SEC-132 cap blob chunk size to 16MiB in import
May 6, 2026
dcb819a
fix(security): SEC-133 cap upload total_size to 10GiB and chunk_size …
May 6, 2026
c3c7607
fix(security): SEC-134 clamp image dimensions to 8192 and buffer to 6…
May 6, 2026
46ff05d
fix(security): SEC-138 clamp remote HLC physical time to max 60s skew
May 6, 2026
b605b02
fix(security): SEC-148 use checked_add for limit/offset to prevent ov…
May 6, 2026
109637c
fix(security): SEC-150 add decompression bomb protection for image de…
May 6, 2026
8f309cb
fix(security): SEC-151 verify process name before killing via PID file
May 6, 2026
4542cd4
fix(security): SEC-147 clamp cursor batch_size to MAX_BATCH_SIZE (10000)
May 6, 2026
88484cf
fix(security): SEC-149 wrap explain_query in spawn_blocking with timeout
May 6, 2026
f1c82fb
fix(security): SEC-160/SEC-169 use OsRng and unwrap_or_default for ti…
May 6, 2026
73d1f97
fix(security): SEC-166 sanitize filename in Content-Disposition header
May 6, 2026
36cd819
fix(security): SEC-163/SEC-167 add finite checks and jitter to reconnect
May 6, 2026
1bc31a1
fix(security): SEC-124/125/126/127/128/129/130/131/132/133/134/138/14…
May 6, 2026
e8e79f0
fix(security): SEC-172 mark handler unwrap task for later
May 6, 2026
d0376e0
fix(security): SEC-170/SEC-173 mark tasks for later
May 6, 2026
672ee2c
fix(security): SEC-161/SEC-171 mark tasks for later
May 6, 2026
28a9527
fix(security): SEC-165/SEC-140 mark tasks for later
May 6, 2026
87c448a
fix(security): SEC-142/SEC-143 mark for later
May 6, 2026
0c97c95
fix(security): SEC-144/SEC-145 mark for later
May 6, 2026
f4faa94
fix(security): SEC-146 mark for later
May 6, 2026
63f7f5f
fix(security): SEC-168/SEC-162 decompress errors fatal, token claims …
May 6, 2026
5457a8d
fix(security): SEC-141/SEC-174 mark for later
May 6, 2026
db715e1
fix(security): SEC-158/SEC-159 mark for later
May 6, 2026
be7b6ee
fix(security): SEC-175 mark for later
May 6, 2026
d518ad1
fix(security): mark remaining architectural tasks as deferred
May 6, 2026
fe84fd0
fix(security): close review gaps in SEC-082/091/122/126/131/148/149/1…
May 6, 2026
556d14e
chore: ignore entire .claude/ directory
May 6, 2026
5c1eb91
fix: repair brace mismatch in solidb-fuse and apply cargo fmt
May 7, 2026
4331caa
test: initialize storage engine and authenticate cluster traffic in i…
May 8, 2026
3843fd9
test: fix sdbql-core test build/expectation and add coverage roadmap …
May 8, 2026
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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@ node_modules
.env
www/.env
www/config/database.json
.claude/settings.local.json
.claude/
/clients/solidb-laravel-eloquent/vendor
/clients/solidb-laravel-eloquent/composer.lock
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ sha2 = "0.10"
hmac = "0.12"
x25519-dalek = { version = "2.0", features = ["static_secrets"] }
md5 = "0.8"
subtle = "2.6"

# Temp directories (needed by benchmark)
tempfile = "3.10"
Expand Down
1 change: 1 addition & 0 deletions sdbql-core/src/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,7 @@ mod tests {
#[test]
fn test_query_default() {
let query = Query {
with_clause: None,
let_clauses: vec![],
for_clauses: vec![],
join_clauses: vec![],
Expand Down
3 changes: 2 additions & 1 deletion sdbql-core/src/executor/builtins/type_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -276,13 +276,14 @@ mod tests {
.unwrap(),
Some(json!(true))
);
// Collection names are case-sensitive (ArangoDB-compatible)
assert_eq!(
call(
"IS_SAME_COLLECTION",
&[json!("users/123"), json!("Users/456")]
)
.unwrap(),
Some(json!(true))
Some(json!(false))
);
assert_eq!(
call("IS_SAME_COLLECTION", &[json!(123), json!("users/456")]).unwrap(),
Expand Down
13 changes: 12 additions & 1 deletion src/bin/solidb-fuse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -790,11 +790,22 @@ fn main() -> anyhow::Result<()> {
match std::fs::read_to_string(&args.pid_file) {
Ok(pid_str) => {
if let Ok(pid) = pid_str.trim().parse::<i32>() {
let mut sys = System::new_all();
sys.refresh_all();

let sys_pid = Pid::from(pid as usize);
if let Some(proc) = sys.process(sys_pid) {
let proc_name = proc.name().to_string_lossy();
if proc_name != "solidb-fuse" && proc_name != "solidb" {
eprintln!("SECURITY ERROR: Process with PID {} is named '{}', not 'solidb-fuse'. Refusing to kill potential mismatch.", pid, proc_name);
return Ok(());
}
}

eprintln!(
"Found existing FUSE process with PID {}. Stopping it...",
pid
);
// Send SIGTERM to gracefully stop the process
unsafe {
libc::kill(pid, libc::SIGTERM);
}
Expand Down
11 changes: 9 additions & 2 deletions src/cluster/hlc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};

/// Maximum allowed clock skew between nodes (60 seconds in ms)
const MAX_CLOCK_SKEW_MS: u64 = 60_000;

/// Hybrid Logical Clock for distributed ordering of events.
/// Combines physical time with a logical counter to ensure unique, ordered timestamps
/// even when wall clocks are out of sync.
Expand Down Expand Up @@ -58,8 +61,11 @@ impl HybridLogicalClock {

/// Update this clock after receiving a message with another clock.
/// Returns a new clock that is greater than both self and the received clock.
/// Remote times beyond MAX_CLOCK_SKEW_MS in the future are clamped to prevent
/// a malicious peer from permanently wedging the clock.
pub fn receive(&self, other: &HybridLogicalClock, node_id: &str) -> Self {
let now = current_time_ms();
let max_allowed_time = now.saturating_add(MAX_CLOCK_SKEW_MS);

let (physical, logical) = if now > self.physical_time && now > other.physical_time {
// Wall clock is ahead of both, reset counter
Expand All @@ -72,8 +78,9 @@ impl HybridLogicalClock {
(self.physical_time, self.logical_counter + 1)
}
} else if other.physical_time > self.physical_time {
// Remote clock is ahead
(other.physical_time, other.logical_counter + 1)
// Remote clock is ahead - clamp to prevent unbounded skew attack
let clamped_remote = other.physical_time.min(max_allowed_time);
(clamped_remote, other.logical_counter + 1)
} else {
// Same physical time, take max logical and increment
(
Expand Down
15 changes: 15 additions & 0 deletions src/driver/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,21 @@ impl DriverHandler {

/// Execute a command and return a response
async fn execute_command(&mut self, command: Command) -> Response {
// Gate every command behind authentication. Only Ping and Auth are
// allowed before the connection has authenticated. Batch is allowed
// through here so its inner commands are re-checked individually
// (an Auth inside a batch will set state for subsequent entries).
if self.authenticated_db.is_none() {
match &command {
Command::Ping | Command::Auth { .. } | Command::Batch { .. } => {}
_ => {
return Response::error(DriverError::AuthError(
"Authentication required".to_string(),
));
}
}
}

match command {
// ==================== Auth & Utility ====================
Command::Ping => Response::pong(),
Expand Down
42 changes: 35 additions & 7 deletions src/queue/jobs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@ use crate::scripting::ScriptEngine;
use crate::storage::StorageEngine;
use std::sync::Arc;

fn validate_script_path(script_path: &str) -> Result<(), crate::error::DbError> {
if script_path.is_empty() {
return Err(crate::error::DbError::BadRequest(
"Script path cannot be empty".to_string(),
));
}
if script_path.len() > 512 {
return Err(crate::error::DbError::BadRequest(
"Script path exceeds maximum length of 512 characters".to_string(),
));
}
let re = regex::Regex::new(r"^[A-Za-z0-9_/\-.]+$").unwrap();
if !re.is_match(script_path) {
return Err(crate::error::DbError::BadRequest(
"Script path contains invalid characters".to_string(),
));
}
Ok(())
}

impl QueueWorker {
pub async fn check_jobs(&self) {
let _lock = match self.claiming_lock.try_lock() {
Expand Down Expand Up @@ -164,19 +184,27 @@ impl QueueWorker {
job: &Job,
db_name: &str,
) -> Result<(), crate::error::DbError> {
validate_script_path(&job.script_path)?;

tracing::info!("Executing job {} with script {}", job.id, job.script_path);

let _db = storage.get_database(db_name)?;

// Find script by path
let query_str = format!(
"FOR s IN _scripts FILTER s.path == '{}' RETURN s",
job.script_path
);
let query_ast = crate::sdbql::parse(&query_str)
let query_str = "FOR s IN _scripts FILTER s.path == @script_path RETURN s";
let query_ast = crate::sdbql::parse(query_str)
.map_err(|e| crate::error::DbError::BadRequest(e.to_string()))?;

let executor = crate::sdbql::QueryExecutor::with_database(storage, db_name.to_string());
let mut bind_vars = crate::sdbql::BindVars::new();
bind_vars.insert(
"script_path".to_string(),
serde_json::json!(job.script_path),
);

let executor = crate::sdbql::QueryExecutor::with_database_and_bind_vars(
storage,
db_name.to_string(),
bind_vars,
);
let result = executor.execute(&query_ast)?;

let script_val = result.first().ok_or_else(|| {
Expand Down
53 changes: 45 additions & 8 deletions src/scripting/file_handling.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ const CHUNK_SIZE: usize = 1024 * 1024;
/// Files collection name
const FILES_COLLECTION: &str = "_files";

/// Maximum image dimension (width or height) to prevent memory exhaustion
const MAX_IMAGE_DIMENSION: u32 = 8192;

/// Maximum image buffer size (64 MiB) to prevent memory exhaustion
const MAX_IMAGE_BYTES: usize = 64 * 1024 * 1024;

/// Detect MIME type from file extension
fn mime_from_extension(ext: &str) -> &'static str {
match ext.to_lowercase().as_str() {
Expand Down Expand Up @@ -206,10 +212,26 @@ pub fn create_upload_function(
chunk_index += 1;
}

// Build path (directory/filename or just filename)
// Build path (directory/filename or just filename).
// Reject anything that resolves to a parent-dir component or absolute
// path. Component-based check avoids substring false-positives
// (e.g. legitimate filename `v1..2.bin`) while still catching
// `..`, `foo/../bar`, `\..\bar`, and absolute paths.
let path = if let Some(ref dir) = directory {
let safe_dir = dir.replace("..", "").replace("//", "/");
format!("{}/{}", safe_dir.trim_matches('/'), safe_filename)
let normalized = dir.replace('\\', "/");
let p = std::path::Path::new(&normalized);
let bad_component = p.components().any(|c| {
matches!(
c,
std::path::Component::ParentDir | std::path::Component::RootDir
)
});
if bad_component || p.is_absolute() {
return Err(mlua::Error::RuntimeError(
"upload: directory path contains invalid traversal patterns".to_string(),
));
}
format!("{}/{}", normalized.trim_matches('/'), safe_filename)
} else {
safe_filename.clone()
};
Expand Down Expand Up @@ -239,7 +261,7 @@ pub fn create_upload_function(

// Add image dimensions if applicable
if mime_type.starts_with("image/") {
if let Ok(img) = image::load_from_memory(&bytes) {
if let Ok(img) = load_image_with_limits(&bytes) {
let (width, height) = img.dimensions();
metadata.insert("width".to_string(), JsonValue::Number(width.into()));
metadata.insert("height".to_string(), JsonValue::Number(height.into()));
Expand Down Expand Up @@ -562,12 +584,27 @@ pub fn create_image_process_function(
})
}

/// Load image with decompression bomb protection
#[allow(deprecated)]
fn load_image_with_limits(bytes: &[u8]) -> Result<DynamicImage, mlua::Error> {
use image::Limits;
let mut reader = image::ImageReader::new(Cursor::new(bytes))
.with_guessed_format()
.map_err(|e| mlua::Error::RuntimeError(format!("failed to guess image format: {}", e)))?;
let mut limits = Limits::default();
limits.max_image_width = Some(MAX_IMAGE_DIMENSION);
limits.max_image_height = Some(MAX_IMAGE_DIMENSION);
limits.max_alloc = Some(MAX_IMAGE_BYTES as u64);
reader.limits(limits);
let img = reader
.decode()
.map_err(|e| mlua::Error::RuntimeError(format!("failed to decode image: {}", e)))?;
Ok(img)
}

/// Process image with given operations
fn process_image(lua: &Lua, bytes: Vec<u8>, operations: Table) -> LuaResult<Table> {
// Load image
let mut img = image::load_from_memory(&bytes).map_err(|e| {
mlua::Error::RuntimeError(format!("image_process: failed to load image: {}", e))
})?;
let mut img = load_image_with_limits(&bytes)?;

// Apply operations
// Resize
Expand Down
94 changes: 94 additions & 0 deletions src/scripting/http_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,86 @@ impl HttpCache {
}
}

/// Parse an origin entry from `SOLIDB_ALLOWED_REDIRECT_ORIGINS` into
/// (scheme, host, port). Accepts forms `host`, `scheme://host`, `scheme://host:port`.
/// `host`-only entries match either http or https.
fn parse_allowed_origin(entry: &str) -> Option<(Option<String>, String, Option<u16>)> {
let entry = entry.trim();
if entry.is_empty() {
return None;
}
if entry.contains("://") {
let parsed = url::Url::parse(entry).ok()?;
let host = parsed.host_str()?.to_lowercase();
Some((Some(parsed.scheme().to_string()), host, parsed.port()))
} else {
// Bare host (and optional :port)
let (host, port) = match entry.rsplit_once(':') {
Some((h, p)) if p.chars().all(|c| c.is_ascii_digit()) => {
(h.to_lowercase(), p.parse::<u16>().ok())
}
_ => (entry.to_lowercase(), None),
};
Some((None, host, port))
}
}

/// Returns true iff `url` matches one of the configured allowed origins.
/// Match is by exact (scheme, host, port) — never substring.
fn redirect_url_allowed(url_str: &str, allowed: &[&str]) -> bool {
let parsed = match url::Url::parse(url_str) {
Ok(u) => u,
Err(_) => return false,
};
let url_host = match parsed.host_str() {
Some(h) => h.to_lowercase(),
None => return false,
};
let url_scheme = parsed.scheme();
let url_port = parsed.port_or_known_default();

allowed.iter().any(|raw| {
let (allowed_scheme, allowed_host, allowed_port) = match parse_allowed_origin(raw) {
Some(t) => t,
None => return false,
};
if allowed_host != url_host {
return false;
}
if let Some(scheme) = &allowed_scheme {
if scheme != url_scheme {
return false;
}
}
if let Some(port) = allowed_port {
if Some(port) != url_port {
return false;
}
}
true
})
}

/// Create solidb.redirect(url) -> error with redirect status function
pub fn create_redirect_function(lua: &Lua) -> LuaResult<Function> {
lua.create_function(|_, url: String| {
let allowed_origins = std::env::var("SOLIDB_ALLOWED_REDIRECT_ORIGINS").unwrap_or_default();
let allowed_list: Vec<&str> = allowed_origins
.split(',')
.map(str::trim)
.filter(|o| !o.is_empty())
.collect();

// Absolute URLs are checked against the allowlist when one is configured.
// Relative paths and (when no allowlist is set) absolute URLs are passed through —
// SEC-095 made the allowlist opt-in.
let is_absolute = url.starts_with("http://") || url.starts_with("https://");
if is_absolute && !allowed_list.is_empty() && !redirect_url_allowed(&url, &allowed_list) {
return Err(mlua::Error::RuntimeError(
"REDIRECT: Forbidden - redirect to untrusted domain".to_string(),
));
}

Err::<LuaValue, mlua::Error>(mlua::Error::RuntimeError(format!("REDIRECT:{}", url)))
})
}
Expand Down Expand Up @@ -171,6 +248,23 @@ pub fn create_response_html_function(_lua: &Lua) -> LuaResult<Function> {
pub fn create_response_file_function(_lua: &Lua) -> LuaResult<Function> {
let lua_ref = _lua;
lua_ref.create_function(move |lua, path: String| {
// Security: reject absolute paths and any ParentDir component.
// Component-based check avoids false positives on legit names like `v1.2..md`
// and false negatives on tricks substring-matching would miss.
let p = std::path::Path::new(&path);
let has_parent_dir = p
.components()
.any(|c| matches!(c, std::path::Component::ParentDir));
if p.is_absolute() || has_parent_dir {
let file_info = lua.create_table()?;
file_info.set(
"error",
"Invalid path: absolute paths and parent-dir traversal are not allowed",
)?;
file_info.set("exists", false)?;
return Ok(LuaValue::Table(file_info));
}

// Check if file exists and get its metadata
match std::fs::metadata(&path) {
Ok(metadata) => {
Expand Down
Loading
Loading