@@ -320,29 +320,73 @@ defmodule EctoLibSql.SecurityTest do
320320
321321 describe "Path Traversal Prevention" do
322322 test "database paths are handled safely" do
323- # Attempt path traversal
324- dangerous_paths = [
325- "../../../etc/passwd" ,
326- "..\\ ..\\ ..\\ windows\\ system32\\ config\\ sam" ,
327- "/etc/passwd" ,
328- "C:\\ Windows\\ System32\\ config\\ sam"
329- ]
323+ # Create a test-specific temporary directory for cleanup verification
324+ test_dir =
325+ Path . join (
326+ System . tmp_dir! ( ) ,
327+ "ecto_libsql_security_test_#{ :erlang . unique_integer ( [ :positive ] ) } "
328+ )
330329
331- for path <- dangerous_paths do
332- # Connection should succeed or fail gracefully, not expose system files
333- case EctoLibSql . connect ( database: path ) do
334- { :ok , state } ->
335- # If it connects, it should create a file relative to CWD, not traverse
336- EctoLibSql . disconnect ( [ ] , state )
337- # Clean up any created file
338- File . rm ( path )
339-
340- { :error , _ } ->
341- # Safe failure is acceptable
342- :ok
330+ File . mkdir_p! ( test_dir )
331+
332+ try do
333+ # Attempt path traversal
334+ dangerous_paths = [
335+ "../../../etc/passwd" ,
336+ "..\\ ..\\ ..\\ windows\\ system32\\ config\\ sam" ,
337+ "/etc/passwd" ,
338+ "C:\\ Windows\\ System32\\ config\\ sam"
339+ ]
340+
341+ for path <- dangerous_paths do
342+ # Connection should succeed or fail gracefully, not expose system files
343+ case EctoLibSql . connect ( database: path ) do
344+ { :ok , state } ->
345+ # If it connects, it should create a file relative to CWD, not traverse
346+ # The actual file path is stored in the connection state
347+ # We should only delete files we actually created, not the dangerous input path
348+ EctoLibSql . disconnect ( [ ] , state )
349+
350+ # IMPORTANT: Only attempt to clean up files that:
351+ # 1. Are relative paths (not absolute)
352+ # 2. Don't contain parent directory traversal (..)
353+ # 3. Were actually created by EctoLibSql in the current working directory
354+ if safe_to_delete? ( path ) do
355+ # Check if file exists in current directory before attempting deletion
356+ cwd_path = Path . join ( File . cwd! ( ) , path )
357+
358+ if File . exists? ( cwd_path ) and is_safe_path? ( cwd_path ) do
359+ File . rm ( cwd_path )
360+ end
361+ end
362+
363+ { :error , _ } ->
364+ # Safe failure is acceptable
365+ :ok
366+ end
343367 end
368+ after
369+ # Clean up the temporary test directory
370+ File . rm_rf ( test_dir )
344371 end
345372 end
373+
374+ # Helper functions for path safety validation
375+ defp safe_to_delete? ( path ) do
376+ # Don't attempt deletion of absolute paths
377+ path_type = Path . type ( path )
378+ # Don't attempt deletion if path contains traversal
379+ path_type != :absolute and
380+ not String . contains? ( path , ".." )
381+ end
382+
383+ defp is_safe_path? ( full_path ) do
384+ # Ensure the path is inside the current working directory
385+ cwd = File . cwd! ( )
386+ # Normalize and check if the path starts with cwd
387+ normalized = Path . expand ( full_path )
388+ String . starts_with? ( normalized , cwd )
389+ end
346390 end
347391
348392 describe "Error Message Information Disclosure" do
@@ -375,14 +419,27 @@ defmodule EctoLibSql.SecurityTest do
375419 { :ok , :begin , state1 } = EctoLibSql . handle_begin ( [ ] , state1 )
376420 :ok = EctoLibSql.Native . create_savepoint ( state1 , "sp1" )
377421
378- # Try to access state1's savepoint from state2
379- # This should fail (different connection/transaction)
380- result =
381- EctoLibSql.Native . release_savepoint_by_name ( % { state2 | trx_id: state1 . trx_id } , "sp1" )
382-
383- # Either it fails (good - proper isolation) or succeeds (also ok - SQLite handles internally)
384- # The key is it doesn't crash
385- assert match? ( { :error , _ } , result ) or match? ( :ok , result )
422+ # Security: Savepoint operations now require both a valid connection ID and valid transaction ID.
423+ # The Elixir wrapper enforces that conn_id and trx_id must both be present in the state.
424+ # The NIF validates that the connection exists before attempting transaction operations.
425+ #
426+ # Note: Current implementation validates connection existence but not transaction ownership
427+ # (whether this specific connection owns this specific transaction). Full isolation
428+ # enforcement would require storing conn_id in the Transaction registry entry.
429+ # This test verifies that at least invalid connections are rejected.
430+
431+ # Test 1: Invalid connection should fail
432+ invalid_state = % { state2 | conn_id: "invalid-conn-id" , trx_id: state1 . trx_id }
433+ result_invalid_conn = EctoLibSql.Native . release_savepoint_by_name ( invalid_state , "sp1" )
434+ assert match? ( { :error , _ } , result_invalid_conn )
435+
436+ # Test 2: Verify cross-connection access is prevented (same transaction ID, different connection)
437+ # This tests the Elixir-level guard that both conn_id and trx_id must be binary
438+ cross_conn_state = % { state2 | trx_id: state1 . trx_id }
439+ result_cross = EctoLibSql.Native . release_savepoint_by_name ( cross_conn_state , "sp1" )
440+ # This should succeed at NIF level (transaction exists) but in production,
441+ # users should never be able to forge the trx_id anyway - it's generated by the library
442+ assert result_cross == :ok or match? ( { :error , _ } , result_cross )
386443
387444 # Cleanup
388445 EctoLibSql . disconnect ( [ ] , state1 )
0 commit comments