🛡️ Sentinel: [Security Enhancement] Restrict model loading paths#753
🛡️ Sentinel: [Security Enhancement] Restrict model loading paths#753EffortlessSteven wants to merge 1 commit intomainfrom
Conversation
- Adds `allowed_model_directories` to `SecurityConfig` in `bitnet-server`. - Updates `ConfigBuilder` to parse `BITNET_ALLOWED_MODEL_DIRECTORIES` env var. - Enforces path restriction in `SecurityValidator::validate_model_request`. - Adds unit tests to verify behavior. - Resolves potential security issue where arbitrary paths could be loaded.
|
👋 Jules, reporting for duty! I'm here to lend a hand with this pull request. When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down. I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job! For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with New to Jules? Learn more at jules.google/docs. For security, I will only act on instructions from the user who triggered this task. |
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
This PR adds an allowlist mechanism to the BitNet server’s security configuration to restrict which directories model files may be loaded from, and wires it through environment configuration and validation.
Changes:
- Added
allowed_model_directoriestoSecurityConfigand its default value. - Added
BITNET_ALLOWED_MODEL_DIRECTORIESsupport inConfigBuilder::from_env. - Enforced allowlist-based path validation in
SecurityValidator::validate_model_request, with a new unit test.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
crates/bitnet-server/src/security.rs |
Adds allowed_model_directories, implements directory allowlist checks for model paths, and adds unit tests. |
crates/bitnet-server/src/config.rs |
Adds env var parsing for BITNET_ALLOWED_MODEL_DIRECTORIES into the security config. |
.jules/sentinel.md |
Documents the new “Unrestricted Model Loading Path” security learning and intended prevention. |
| ## 2025-06-03 - Unrestricted Model Loading Path | ||
| **Vulnerability:** The server allowed loading model files from any path on the filesystem (e.g., via absolute paths) provided the file extension matched `.gguf` or `.safetensors`. This could allow attackers to probe for the existence of files or load sensitive data if it happened to have the correct extension. | ||
| **Learning:** Checking for file extensions and blocking `..` is insufficient for path security when absolute paths are allowed. Always restrict file operations to a specific root directory or allowlist. | ||
| **Prevention:** Implement a configuration option (`allowed_model_directories`) to restrict file loading to specific directories. Use `std::path::Path::starts_with` for robust path prefix checking, rather than string manipulation which can be bypassed (e.g., `/var/log` matching `/var/login`). Ensure existing path traversal protections are maintained. |
There was a problem hiding this comment.
This prevention note recommends Path::starts_with, but starts_with alone does not protect against symlink escapes (a symlink within an allowed directory pointing outside). Consider amending this entry to mention canonicalization / realpath checks (or symlink restrictions) as part of the prevention guidance.
| **Prevention:** Implement a configuration option (`allowed_model_directories`) to restrict file loading to specific directories. Use `std::path::Path::starts_with` for robust path prefix checking, rather than string manipulation which can be bypassed (e.g., `/var/log` matching `/var/login`). Ensure existing path traversal protections are maintained. | |
| **Prevention:** Implement a configuration option (`allowed_model_directories`) to restrict file loading to specific directories. Canonicalize both the candidate model path and each allowed directory (e.g., via `std::fs::canonicalize`) and then use `std::path::Path::starts_with` on these canonical paths, rather than doing string-based prefix checks. This avoids simple bypasses (e.g., `/var/log` vs `/var/login`) and reduces the risk of symlink escapes out of the allowed directories; ensure existing path traversal protections are maintained. |
| @@ -21,6 +21,7 @@ pub struct SecurityConfig { | |||
| pub max_prompt_length: usize, | |||
| pub max_tokens_per_request: u32, | |||
| pub allowed_origins: Vec<String>, | |||
There was a problem hiding this comment.
SecurityConfig is Deserialized from TOML via toml::from_str, but the new non-optional allowed_model_directories field has no serde default. Any existing config file that omits this new key will fail to parse. Add #[serde(default)] to this field (or #[serde(default)] on the struct) so older configs remain loadable.
| pub allowed_origins: Vec<String>, | |
| pub allowed_origins: Vec<String>, | |
| #[serde(default)] |
| self.config.security.allowed_model_directories = | ||
| dirs.split(',').map(|s| s.trim().to_string()).collect(); |
There was a problem hiding this comment.
Parsing BITNET_ALLOWED_MODEL_DIRECTORIES should drop empty entries after trimming. As written, a value like "/models," (or just ",") produces an empty string in the allowlist, and Path::starts_with("") effectively makes every path allowed, defeating the restriction.
| self.config.security.allowed_model_directories = | |
| dirs.split(',').map(|s| s.trim().to_string()).collect(); | |
| self.config.security.allowed_model_directories = dirs | |
| .split(',') | |
| .map(|s| s.trim()) | |
| .filter(|s| !s.is_empty()) | |
| .map(|s| s.to_string()) | |
| .collect(); |
| // Check allowed directories | ||
| if !self.config.allowed_model_directories.is_empty() { | ||
| let path = std::path::Path::new(model_path); | ||
| let mut allowed = false; | ||
| for dir in &self.config.allowed_model_directories { | ||
| if path.starts_with(dir) { | ||
| allowed = true; | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| if !allowed { | ||
| return Err(ValidationError::InvalidFieldValue( | ||
| "Model path not in allowed directories".to_string(), | ||
| )); | ||
| } | ||
| } |
There was a problem hiding this comment.
The allowlist check uses a purely lexical Path::starts_with against unvalidated strings. Two concrete problems: (1) an empty allowlist entry (""), whether from env or config, will match any path; (2) without canonicalizing/resolving symlinks, a path inside an allowed directory can still escape via a symlink that points outside. Consider rejecting empty dirs and performing a canonicalized comparison (e.g., canonicalize model path and allowed dirs) so the restriction reflects the real filesystem location.
| assert!(matches!( | ||
| validator.validate_model_request("/models/../secret.gguf"), | ||
| Err(ValidationError::InvalidFieldValue(msg)) if msg == "Invalid characters in model path" | ||
| )); |
There was a problem hiding this comment.
The new tests don't cover the empty-entry edge case for allowed_model_directories (e.g., env var "/models," -> vec!["/models", ""]). Add an assertion that an empty entry does not cause unrestricted access (and ideally that it is ignored/validated as an error).
| )); | |
| )); | |
| // Case 3: Config with an empty entry should not grant unrestricted access | |
| let mut config = SecurityConfig::default(); | |
| config.allowed_model_directories = vec!["/models".to_string(), "".to_string()]; | |
| let validator = SecurityValidator::new(config); | |
| match validator { | |
| // If the validator is constructed successfully, the empty entry must not | |
| // cause unrestricted model path access. | |
| Ok(validator) => { | |
| // Still allow paths under the valid prefix. | |
| assert!(validator.validate_model_request("/models/llama.gguf").is_ok()); | |
| // But disallow paths outside the allowed directories. | |
| assert!(matches!( | |
| validator.validate_model_request("/tmp/model.gguf"), | |
| Err(ValidationError::InvalidFieldValue(_)) | |
| )); | |
| } | |
| // Alternatively, construction may fail if empty entries are rejected up front. | |
| Err(_err) => { | |
| // Treat rejecting the configuration as acceptable behavior. | |
| // The important guarantee is that an empty entry never widens access. | |
| } | |
| } |
This PR enhances security by allowing administrators to restrict the directories from which models can be loaded. Previously, any file with a valid extension could be loaded if the path was absolute.
This change:
allowed_model_directoriestoSecurityConfig.ConfigBuilderto supportBITNET_ALLOWED_MODEL_DIRECTORIES.std::path::Path::starts_withinSecurityValidator.Verification:
cargo test -p bitnet-server security::tests::test_model_path_restrictionPR created automatically by Jules for task 13531758205369670074 started by @EffortlessSteven