44config_manager.py
55
66Handles loading configuration from config.ini and command-line arguments.
7+
8+ FIXES APPLIED:
9+ - Added path sanitization for security (High Priority #16)
10+ - Improved file path validation
11+ - Added system prompt file size limit
712"""
813
914import configparser
1217import sys
1318import os
1419import ollama
15- from typing import Any , Tuple , Optional , Literal # Literal imported but only used for type checking clarity
20+ from typing import Any , Tuple , Optional
1621
1722# Import defaults and helpers from audio_utils
1823try :
2732
2833CONFIG_FILE_NAME = 'config.ini'
2934
35+ # FIX #16: Security constants for path validation
36+ MAX_SYSTEM_PROMPT_FILE_SIZE = 10 * 1024 # 10KB limit for system prompt files
37+ ALLOWED_MODEL_DIRECTORIES = ['models' , 'Models' , './models' , './Models' ]
38+
39+ def sanitize_file_path (file_path : str , description : str = "file" ) -> str :
40+ """
41+ FIX #16: Sanitizes and validates file paths to prevent path traversal attacks.
42+
43+ Args:
44+ file_path: The path to sanitize
45+ description: Description of the file for error messages
46+
47+ Returns:
48+ Absolute path if valid
49+
50+ Raises:
51+ ValueError: If path is invalid or potentially malicious
52+ """
53+ if not file_path :
54+ raise ValueError (f"Empty { description } path provided" )
55+
56+ # Get absolute path and normalize
57+ abs_path = os .path .abspath (file_path )
58+
59+ # Check for path traversal attempts
60+ if '..' in file_path :
61+ logging .warning (f"Path traversal detected in { description } path: { file_path } " )
62+ raise ValueError (f"Invalid { description } path: path traversal not allowed" )
63+
64+ # For model files, ensure they're in allowed directories
65+ if 'model' in description .lower ():
66+ path_valid = False
67+ current_dir = os .path .abspath ('.' )
68+
69+ for allowed_dir in ALLOWED_MODEL_DIRECTORIES :
70+ allowed_abs = os .path .abspath (allowed_dir )
71+ try :
72+ # Check if the file is within an allowed directory
73+ os .path .commonpath ([allowed_abs , abs_path ])
74+ if abs_path .startswith (allowed_abs ):
75+ path_valid = True
76+ break
77+ except ValueError :
78+ # Paths are on different drives (Windows) or not related
79+ continue
80+
81+ if not path_valid :
82+ # Allow absolute paths that exist (for custom installations)
83+ if os .path .exists (abs_path ):
84+ logging .warning (f"{ description } path outside standard directories: { abs_path } " )
85+ path_valid = True
86+
87+ if not path_valid :
88+ raise ValueError (f"Invalid { description } path: must be in models/ directory or provide absolute path" )
89+
90+ return abs_path
91+
3092def get_ollama_client (ollama_host : str ) -> Optional [ollama .Client ]:
3193 """
3294 Tries to connect to the Ollama server and returns a client instance.
@@ -40,9 +102,7 @@ def get_ollama_client(ollama_host: str) -> Optional[ollama.Client]:
40102 return client
41103 except Exception as e :
42104 logging .error (f"Failed to connect to Ollama at { ollama_host } : { e } " )
43- # --- FIX: Changed advisory log level from error to warning ---
44105 logging .warning ("Please ensure Ollama is running and the 'ollama_host' in config.ini is correct." )
45- # --- END FIX ---
46106 return None
47107
48108# Define a custom type converter for device indices that handles 'none'
@@ -55,9 +115,7 @@ def device_index_type(value: str) -> Optional[int]:
55115 except ValueError :
56116 raise argparse .ArgumentTypeError (f"Invalid device index: '{ value } '. Must be an integer or 'none'." )
57117
58- # --- IMPROVEMENT: Changed return type hint from Literal[True, False] to bool ---
59118def load_config_and_args () -> Tuple [argparse .Namespace , configparser .ConfigParser , bool ]:
60- # --- END IMPROVEMENT ---
61119 """
62120 Loads settings from config.ini, parses command-line arguments,
63121 and sets up logging.
@@ -72,15 +130,13 @@ def load_config_and_args() -> Tuple[argparse.Namespace, configparser.ConfigParse
72130 logging .info (f"Loaded configuration from { CONFIG_FILE_NAME } " )
73131 config_loaded = True
74132 else :
75- # Logging setup is now expected to be done in assistant.py
76133 logging .warning (f"{ CONFIG_FILE_NAME } not found. Using default settings and CLI args." )
77134
78135 config_models = config ['Models' ] if 'Models' in config else {}
79136 config_func = config ['Functionality' ] if 'Functionality' in config else {}
80137
81138 def get_config_val (section : configparser .SectionProxy , key : str , default : Any , type_converter : type ) -> Any :
82139 """Helper to get and convert config values, handling 'none' string and missing config file."""
83- # Use config_loaded flag to prevent logging warnings when config is totally missing
84140 if not config_loaded :
85141 return default
86142
@@ -96,7 +152,6 @@ def get_config_val(section: configparser.SectionProxy, key: str, default: Any, t
96152
97153 return type_converter (val )
98154 except (ValueError , configparser .NoOptionError ):
99- # Only warn if config was loaded but had an invalid value
100155 logging .warning (f"Invalid value '{ val } ' for '{ key } ' in config.ini. Using default: { default } " )
101156 return default
102157
@@ -129,6 +184,7 @@ def get_config_val(section: configparser.SectionProxy, key: str, default: Any, t
129184 func_group .add_argument ('--whisper-device' , type = str , default = DEFAULT_SETTINGS ['whisper_device' ], help = "Device for Whisper (e.g., 'cpu', 'cuda')." )
130185 func_group .add_argument ('--whisper-compute-type' , type = str , default = DEFAULT_SETTINGS ['whisper_compute_type' ], help = "Compute type for Whisper (e.g., 'int8', 'float16')." )
131186 func_group .add_argument ('--max-history-tokens' , type = int , default = DEFAULT_SETTINGS ['max_history_tokens' ], help = "Maximum token context for chat history." )
187+ func_group .add_argument ('--audio-buffer-size' , type = int , default = DEFAULT_SETTINGS ['audio_buffer_size' ], help = "Size of the audio buffer queue (default: 200)." )
132188
133189 # Apply configuration defaults
134190 parser .set_defaults (
@@ -155,7 +211,8 @@ def get_config_val(section: configparser.SectionProxy, key: str, default: Any, t
155211 max_words_per_command = get_config_val (config_func , 'max_words_per_command' , DEFAULT_SETTINGS ['max_words_per_command' ], int ),
156212 whisper_device = get_config_val (config_func , 'whisper_device' , DEFAULT_SETTINGS ['whisper_device' ], str ),
157213 whisper_compute_type = get_config_val (config_func , 'whisper_compute_type' , DEFAULT_SETTINGS ['whisper_compute_type' ], str ),
158- max_history_tokens = get_config_val (config_func , 'max_history_tokens' , DEFAULT_SETTINGS ['max_history_tokens' ], int )
214+ max_history_tokens = get_config_val (config_func , 'max_history_tokens' , DEFAULT_SETTINGS ['max_history_tokens' ], int ),
215+ audio_buffer_size = get_config_val (config_func , 'audio_buffer_size' , DEFAULT_SETTINGS ['audio_buffer_size' ], int )
159216 )
160217
161218 args = parser .parse_args ()
@@ -165,25 +222,46 @@ def get_config_val(section: configparser.SectionProxy, key: str, default: Any, t
165222 logging .getLogger ().setLevel (logging .DEBUG )
166223 logging .debug ("DEBUG logging enabled." )
167224
168- # Check if system_prompt is a file path
225+ # FIX #16: Sanitize model file paths
226+ try :
227+ args .wakeword_model_path = sanitize_file_path (args .wakeword_model_path , "wakeword model" )
228+ args .piper_model_path = sanitize_file_path (args .piper_model_path , "Piper TTS model" )
229+ except ValueError as e :
230+ logging .critical (f"Security error: { e } " )
231+ logging .critical ("Please check your model paths in config.ini or command-line arguments." )
232+ sys .exit (1 )
233+
234+ # FIX #16: Check if system_prompt is a file path with validation
169235 if args .system_prompt and os .path .isfile (args .system_prompt ):
170236 logging .info (f"Loading system prompt from file: { args .system_prompt } " )
171- file_content = None
237+
172238 try :
173- with open (args .system_prompt , 'r' , encoding = 'utf-8' ) as f :
239+ # Sanitize the path
240+ prompt_file_path = sanitize_file_path (args .system_prompt , "system prompt file" )
241+
242+ # FIX #16: Check file size before reading
243+ file_size = os .path .getsize (prompt_file_path )
244+ if file_size > MAX_SYSTEM_PROMPT_FILE_SIZE :
245+ raise ValueError (f"System prompt file too large ({ file_size } bytes). Maximum allowed: { MAX_SYSTEM_PROMPT_FILE_SIZE } bytes" )
246+
247+ with open (prompt_file_path , 'r' , encoding = 'utf-8' ) as f :
174248 file_content = f .read ().strip ()
175249
176- # --- IMPROVEMENT: Explicit fallback if file is empty ---
177250 if file_content :
178251 args .system_prompt = file_content
252+ logging .info (f"Loaded system prompt ({ len (file_content )} characters) from file." )
179253 else :
180254 logging .warning (f"System prompt file '{ args .system_prompt } ' is empty. Using default." )
181255 args .system_prompt = DEFAULT_SETTINGS ['system_prompt' ]
256+
257+ except ValueError as e :
258+ logging .error (f"Security error with system prompt file: { e } " )
259+ logging .warning ("Using the default system prompt instead." )
260+ args .system_prompt = DEFAULT_SETTINGS ['system_prompt' ]
182261 except Exception as e :
183262 logging .error (f"Failed to read system prompt file '{ args .system_prompt } ': { e } " )
184263 logging .warning ("Using the default system prompt instead." )
185264 args .system_prompt = DEFAULT_SETTINGS ['system_prompt' ]
186- # --- END IMPROVEMENT ---
187265
188266 # Device listing logic
189267 if args .list_devices or args .list_output_devices :
0 commit comments